作者: andylin02
学习章节: 第14章 高级I/O
关键词:非阻塞I/O;记录锁;I/O多路转接;select;poll;epoll;异步I/O;散布读;聚集写;存储映射I/O;mmap

一、章节概述

学习目标:掌握UNIX系统中的高级I/O技术,包括非阻塞I/O、记录锁、I/O多路转接、readv/writev、存储映射I/O等,理解这些技术在多线程和高性能服务器编程中的应用场景。

本章是APUE中承上启下的关键章节。第11-13章讲解了线程与守护进程,第15章开始将介绍进程间通信(IPC),而第14章的高级I/O技术为这些后续章节提供了重要的技术基础。主要涵盖:

  • 非阻塞I/O:让低速设备的I/O操作不会永久阻塞进程
  • 记录锁(Record Locking):用于进程间对文件特定区域的互斥访问
  • I/O多路转接:selectpselectpoll函数,实现单进程监控多个文件描述符
  • 异步I/O:进程在I/O操作完成时收到通知
  • readvwritev函数:在一次函数调用中读写多个非连续缓冲区
  • 存储映射I/O(mmap):将磁盘文件映射到内存空间

💡 学习建议:本章内容较多但逻辑清晰,核心思想是“如何让I/O操作更高效”。建议结合服务器编程场景理解——非阻塞I/O与I/O多路转接是高性能网络编程的核心技术,select/poll/epoll的选择对比是面试常考题,值得深入理解。

二、非阻塞I/O

2.1 低速系统调用

对低速设备的I/O操作可能会使进程永久阻塞,这类系统调用主要有以下情况:

  1. 如果数据并不存在,则读文件可能会使调用者永远阻塞(例如读管道、终端设备和网络设备)
  2. 如果数据不能立即被接受,则写这些同样的文件也会使调用者永远阻塞
  3. 在某些条件发生之前,打开文件会被阻塞(例如以只写方式打开一个FIFO,那么在没有其他进程已用读方式打开该FIFO时)
  4. 对已经加上强制性锁的文件进行读、写
  5. 某些ioctl操作
  6. 某些进程间通信函数

📖 拓展知识:低速系统调用(slow system call)是指那些可能会使进程永远阻塞的系统调用。需要注意,与磁盘I/O相关的系统调用通常不被认为是低速的,因为磁盘I/O虽然慢但终究会返回,而管道、终端设备和网络设备的读/写可能无限期等待。

2.2 非阻塞I/O的本质

非阻塞I/O使我们可以调用openreadwrite这样的I/O操作,并使这些操作不会永远阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。

非阻塞I/O与普通I/O的对比

特性 普通(阻塞)I/O 非阻塞I/O
操作无法立即完成时 进程阻塞等待 立即返回错误(EAGAINEWOULDBLOCK
CPU使用 等待期间CPU空闲 轮询时CPU被持续占用
适用场景 单描述符、确定有数据的场景 多描述符、不确定哪个先准备好的场景

2.3 设置非阻塞标志的方法

对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:

  1. 创建时指定:如果调用open获得描述符,则可指定O_NONBLOCK标志
  2. 运行时修改:对于已经打开的一个描述符,则可调用fcntl,由该函数打开O_NONBLOCK文件状态标志

📖 注意事项O_NONBLOCK标志对于普通文件(regular file)的文件描述符通常没有阻塞效果——对磁盘文件的读/写操作一般不会阻塞,因此非阻塞标志主要对管道、套接字、终端设备、FIFO等特殊文件有意义。

2.4 非阻塞I/O代码示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>

#define BUFFER_SIZE 100000

char buf[BUFFER_SIZE];

/* 设置文件描述符标志 */
void set_fl(int fd, int flags)
{
    int val;
    if ((val = fcntl(fd, F_GETFL, 0)) < 0) {
        perror("fcntl F_GETFL error");
        exit(1);
    }
    val |= flags;  /* 添加标志 */
    if (fcntl(fd, F_SETFL, val) < 0) {
        perror("fcntl F_SETFL error");
        exit(1);
    }
}

/* 清除文件描述符标志 */
void clr_fl(int fd, int flags)
{
    int val;
    if ((val = fcntl(fd, F_GETFL, 0)) < 0) {
        perror("fcntl F_GETFL error");
        exit(1);
    }
    val &= ~flags;  /* 清除标志 */
    if (fcntl(fd, F_SETFL, val) < 0) {
        perror("fcntl F_SETFL error");
        exit(1);
    }
}

int main(void)
{
    int ntowrite, nwrite;
    char *ptr;
    
    /* 从标准输入读取数据 */
    if ((ntowrite = read(STDIN_FILENO, buf, sizeof(buf))) < 0) {
        perror("read error");
        exit(1);
    }
    fprintf(stderr, "read %d bytes\n", ntowrite);
    
    /* 将标准输出设置为非阻塞模式 */
    set_fl(STDOUT_FILENO, O_NONBLOCK);
    
    ptr = buf;
    while (ntowrite > 0) {
        errno = 0;
        nwrite = write(STDOUT_FILENO, ptr, ntowrite);
        fprintf(stderr, "nwrite = %d, errno = %d\n", nwrite, errno);
        
        if (nwrite > 0) {
            ptr += nwrite;
            ntowrite -= nwrite;
        }
    }
    
    /* 恢复标准输出为阻塞模式 */
    clr_fl(STDOUT_FILENO, O_NONBLOCK);
    
    exit(0);
}

📖 程序说明:程序从标准输入读入100000字节,然后将标准输出设置为非阻塞模式,循环写入直到所有数据写完。若标准输出是终端,write可能只写入部分数据就返回,需要多次循环才能写完。这种方式叫做轮询,在多用户系统中会浪费CPU时间。

三、记录锁

3.1 记录锁的概念

记录锁(record locking)的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。

📖 术语说明:在UNIX系统中,内核并没有“记录”的概念,因此记录锁实际上是“字节范围锁”——锁定文件中的一个字节区域,而不是传统意义上的记录。

3.2 fcntl记录锁

POSIX.1使用fcntl函数作为记录锁的接口:

#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* struct flock *flockptr */);
// 返回值:若成功则依赖于cmd,否则返回-1

对于记录锁,cmd参数可以是以下三个值之一:

cmd 说明
F_GETLK 判断由flockptr所描述的锁是否会被另一把锁所排斥(阻塞)。如果存在冲突锁,则将该锁的信息写入flockptr;如果不存在,则将l_type设置为F_UNLCK
F_SETLK 非阻塞地设置锁。如果与其他进程的锁冲突,立即出错返回,errno设置为EACCESEAGAIN
F_SETLKW 阻塞地设置锁。如果与其他进程的锁冲突,调用进程进入休眠状态,直到锁可用或被信号中断

3.3 flock结构体

flockptr是一个指向flock结构的指针,其定义如下:

struct flock {
    short l_type;      /* 锁类型: F_RDLCK(共享读锁), F_WRLCK(独占写锁), F_UNLCK(解锁) */
    short l_whence;    /* 偏移基准: SEEK_SET, SEEK_CUR, SEEK_END */
    off_t l_start;     /* 区域起始偏移量 */
    off_t l_len;       /* 区域长度(字节),0表示锁定到文件尾 */
    pid_t l_pid;       /* 持有锁的进程ID,仅由F_GETLK返回 */
};

锁互斥规则

锁类型 读锁 写锁
读锁 兼容 互斥
写锁 互斥 互斥

即:任意多个进程可以在一个给定字节上同时持有一把共享的读锁,但一个给定字节上只能有一个进程持有独占的写锁。

3.4 记录锁的隐含继承与释放

关于记录锁的自动继承和释放有以下三条重要规则:

  1. 锁与进程和文件相关联:当一个进程终止时,它所建立的锁全部释放;无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放
  2. fork不继承锁:由fork产生的子进程不继承父进程所设置的锁
  3. exec继承锁:在执行exec后,新程序可以继承原执行程序的锁

3.5 建议性锁与强制性锁

锁类型 说明
建议性锁 内核不强制检查锁,只是为合作进程提供一种互斥机制。如果一个进程不遵循锁规则,仍然可以访问被锁的文件区域
强制性锁 内核会对每一个openreadwrite进行检查,验证调用进程是否违背了正在访问的文件上的锁。如果违背,内核会阻塞该操作直到锁被释放

📖 开启强制性锁:对一个特定文件打开其设置组ID位、关闭其执行位,便开启了对该文件的强制性锁机制。

3.6 记录锁完整示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

/* 测试锁是否存在 */
void test_lock(int fd, short type, off_t start, short whence, off_t len)
{
    struct flock lock;
    lock.l_type = type;
    lock.l_start = start;
    lock.l_whence = whence;
    lock.l_len = len;
    
    if (fcntl(fd, F_GETLK, &lock) < 0) {
        perror("fcntl error");
        exit(1);
    }
    
    if (lock.l_type == F_UNLCK) {
        printf("锁可以设置\n");
    } else {
        printf("锁被进程 %d 持有\n", lock.l_pid);
    }
}

/* 设置锁(非阻塞) */
int set_lock(int fd, short type, off_t start, short whence, off_t len)
{
    struct flock lock;
    lock.l_type = type;
    lock.l_start = start;
    lock.l_whence = whence;
    lock.l_len = len;
    
    return fcntl(fd, F_SETLK, &lock);
}

/* 设置锁(阻塞) */
int set_lock_wait(int fd, short type, off_t start, short whence, off_t len)
{
    struct flock lock;
    lock.l_type = type;
    lock.l_start = start;
    lock.l_whence = whence;
    lock.l_len = len;
    
    return fcntl(fd, F_SETLKW, &lock);
}

/* 解锁区域 */
int unlock(int fd, off_t start, short whence, off_t len)
{
    struct flock lock;
    lock.l_type = F_UNLCK;
    lock.l_start = start;
    lock.l_whence = whence;
    lock.l_len = len;
    
    return fcntl(fd, F_SETLK, &lock);
}

int main(int argc, char *argv[])
{
    int fd;
    
    if (argc != 2) {
        fprintf(stderr, "usage: %s <filename>\n", argv[0]);
        exit(1);
    }
    
    fd = open(argv[1], O_RDWR | O_CREAT, 0644);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    
    /* 测试整个文件是否可以加写锁 */
    printf("测试整个文件的写锁状态:\n");
    test_lock(fd, F_WRLCK, 0, SEEK_SET, 0);
    
    /* 尝试获取写锁 */
    if (set_lock(fd, F_WRLCK, 0, SEEK_SET, 0) == 0) {
        printf("成功获得整个文件的写锁\n");
        
        /* 做一些需要独占访问的操作 */
        printf("正在执行独占操作...\n");
        sleep(5);
        
        /* 释放锁 */
        if (unlock(fd, 0, SEEK_SET, 0) == 0) {
            printf("锁已释放\n");
        }
    } else {
        printf("无法获得写锁: %s\n", strerror(errno));
    }
    
    close(fd);
    return 0;
}

3.7 记录锁死锁处理

如果两个进程互相等待对方持有且不释放的锁,就会进入死锁状态。内核会检测死锁,并选择一个进程接收出错返回。当发生死锁时,fcntl会返回EDEADLK错误。

四、I/O多路转接

4.1 问题的引出

考虑一个需要同时从两个描述符读取数据的程序。如果在一个描述符上进行阻塞读,可能会长时间阻塞在该描述符上,而另一个描述符虽然有很多数据却不能得到及时处理。

4.2 I/O多路转接的概念

I/O多路转接(I/O Multiplexing)先构造一张有关描述符的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。在返回时,它告诉进程哪些描述符已准备好可以进行I/O。

4.3 select函数

select函数使我们可以执行I/O多路转接,它在所有POSIX兼容的平台上都可用。

#include <sys/select.h>

int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds,
           fd_set *restrict exceptfds, struct timeval *restrict tvptr);
// 返回值:准备就绪的描述符数量,超时返回0,出错返回-1

参数说明

  • maxfdp1:最大描述符编号值加1
  • readfds:关心的可读描述符集
  • writefds:关心的可写描述符集
  • exceptfds:关心的异常条件描述符集
  • tvptr:愿意等待的时间,有三种情况:
    • tvptr == NULL:永远等待,直到某个描述符准备好或被信号中断
    • tvptr->tv_sec == 0 && tvptr->tv_usec == 0:完全不等待,轮询
    • 其他值:等待指定的秒数和微秒数

描述符集操作函数

#include <sys/select.h>

void FD_ZERO(fd_set *set);      /* 清除集合中的所有位 */
void FD_SET(int fd, fd_set *set); /* 在集合中添加fd */
void FD_CLR(int fd, fd_set *set); /* 从集合中移除fd */
int FD_ISSET(int fd, fd_set *set); /* 判断fd是否在集合中,是则返回非0 */

4.4 poll函数

pollselect类似,但不是为每个状态构造一个描述集,而是构造了一个pollfd结构数组,每个数组元素指定一个描述符编号以及对其关心的状态。

#include <poll.h>

int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
// 返回值:准备就绪的描述符数量,超时返回0,出错返回-1
struct pollfd {
    int fd;         /* 要检查的描述符 */
    short events;   /* 对该描述符关心的事件 */
    short revents;  /* 该描述符上发生的事件 */
};

events/revents的取值

事件 说明
POLLIN 可以不阻塞地读高优先级数据以外的数据
POLLRDNORM 可以不阻塞地读普通数据
POLLRDBAND 可以不阻塞地读优先级数据
POLLPRI 可以不阻塞地读高优先级数据
POLLOUT 可以不阻塞地写普通数据
POLLWRNORM POLLOUT相同
POLLWRBAND 可以不阻塞地写优先级数据
POLLERR 已出错(只存在于revents)
POLLHUP 已挂起(只存在于revents)
POLLNVAL 描述符没有打开(只存在于revents)

4.5 select/poll对比

特性 select poll
描述符集合表示 使用位图(fd_set) 使用pollfd结构数组
最大描述符数 通常限制为1024(可编译修改) 没有固定上限
效率 每次调用需要重新构建描述符集 不需要重新计算最大描述符
精度 微秒级超时 毫秒级超时
跨平台性 所有POSIX系统 所有POSIX系统(XSI扩展)

4.6 select使用示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <string.h>
#include <errno.h>

int main(void)
{
    fd_set rset;
    struct timeval tv;
    int ret;
    char buf[1024];
    
    while (1) {
        /* 设置关心的描述符集 */
        FD_ZERO(&rset);
        FD_SET(STDIN_FILENO, &rset);
        
        /* 设置超时时间为5秒 */
        tv.tv_sec = 5;
        tv.tv_usec = 0;
        
        /* 等待标准输入准备好 */
        ret = select(STDIN_FILENO + 1, &rset, NULL, NULL, &tv);
        
        if (ret == -1) {
            perror("select error");
            break;
        } else if (ret == 0) {
            printf("5秒内没有输入,超时了\n");
        } else {
            /* 检查标准输入是否可读 */
            if (FD_ISSET(STDIN_FILENO, &rset)) {
                if (fgets(buf, sizeof(buf), stdin) != NULL) {
                    printf("输入: %s", buf);
                }
            }
        }
    }
    
    return 0;
}

4.7 poll使用示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <poll.h>
#include <string.h>

#define TIMEOUT 5000  /* 5秒超时 */

int main(void)
{
    struct pollfd fds[1];
    int ret;
    char buf[1024];
    
    /* 设置要监控的描述符 */
    fds[0].fd = STDIN_FILENO;
    fds[0].events = POLLIN;  /* 关心可读事件 */
    
    while (1) {
        ret = poll(fds, 1, TIMEOUT);
        
        if (ret == -1) {
            perror("poll error");
            break;
        } else if (ret == 0) {
            printf("5秒内没有输入,超时了\n");
        } else {
            /* 检查描述符是否有可读事件 */
            if (fds[0].revents & POLLIN) {
                if (fgets(buf, sizeof(buf), stdin) != NULL) {
                    printf("输入: %s", buf);
                }
            }
        }
    }
    
    return 0;
}

4.8 epoll(Linux特有)

epoll是Linux特有的高性能I/O多路复用机制,相较于selectpoll在大量并发连接场景下性能更优。

特性 select poll epoll
支持的文件描述符数 有限(通常1024) 较大 很大(系统上限)
时间复杂度 O(n) O(n) O(1)
工作模式 水平触发 水平触发 水平触发/边缘触发
用户态到内核态拷贝 每次调用都拷贝 每次调用都拷贝 注册时拷贝一次
适用场景 小规模应用 中等规模 大规模高并发

💡 设计建议selectpoll适合连接数量小、活跃数量多、实时性要求高的情况。epoll适合客户端的连接数量很大、活跃数量小的情况。在高速LAN环境下所有socket都很活跃时,epoll并不比select/poll有明显优势。

五、readv和writev函数

5.1 函数简介

readvwritev函数允许我们只通过一个函数调用,完成读取数据到不连续的缓存或者从不连续的缓存中向外写数据。这些操作被称作散布读(scatter read)和聚集写(gather write)。

#include <sys/uio.h>

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
// 返回值:已读/写的字节数,出错返回-1

iovec结构体定义

struct iovec {
    void *iov_base;   /* 缓冲区的起始地址 */
    size_t iov_len;   /* 缓冲区的大小 */
};

5.2 工作原理

writev函数依序从缓存iov[0]iov[1]、…、iov[iovcnt]中收集输出数据,并返回输出数据的总字节大小,一般等于这些缓存的总长度。readv函数依序向每个数组元素填充数据,每次都填完一个缓存元素之后再填充下一个元素。

5.3 readv/writev使用示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/uio.h>

int main(void)
{
    int fd;
    struct iovec iov[3];
    char buf1[10];
    char buf2[20];
    char buf3[30];
    ssize_t nread, nwrite;
    
    /* 打开文件 */
    fd = open("/var/log/messages", O_RDONLY);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    
    /* 设置iovec数组:指定要读取的3个缓冲区 */
    iov[0].iov_base = buf1;
    iov[0].iov_len = sizeof(buf1);
    iov[1].iov_base = buf2;
    iov[1].iov_len = sizeof(buf2);
    iov[2].iov_base = buf3;
    iov[2].iov_len = sizeof(buf3);
    
    /* 使用readv一次性读取到3个缓冲区 */
    nread = readv(fd, iov, 3);
    if (nread < 0) {
        perror("readv error");
        exit(1);
    }
    printf("readv读取了 %ld 字节\n", (long)nread);
    
    /* 向标准输出一次性写入3个缓冲区 */
    nwrite = writev(STDOUT_FILENO, iov, 3);
    if (nwrite < 0) {
        perror("writev error");
        exit(1);
    }
    printf("\nwritev写入了 %ld 字节\n", (long)nwrite);
    
    close(fd);
    return 0;
}

📖 性能优势readv/writev通过一次系统调用完成多个不连续缓冲区的I/O,减少了系统调用的次数。在数据量较大的场景下,性能提升明显。但当数据量很小时,单次write可能比writev更高效,需要在性能和管理缓存的代价之间权衡。

六、存储映射I/O

6.1 存储映射I/O的概念

存储映射I/O(Memory-mapped I/O)使一个磁盘文件与存储空间中的一个缓冲区相映射。从缓冲区读数据就相当于读文件的相应字节,向缓冲区写数据则会将数据写入文件。

6.2 mmap函数

#include <sys/mman.h>

void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
// 返回值:成功返回映射区的起始地址,出错返回MAP_FAILED

参数说明

参数 说明
addr 映射存储区的起始地址(通常设为0,由系统选择)
len 要映射的字节数
prot 映射存储区的保护要求
flags 映射选项
fd 要映射文件的文件描述符
offset 文件中开始映射的偏移量(必须为页大小的整数倍)

prot保护要求取值

常量 说明
PROT_READ 映射区可读
PROT_WRITE 映射区可写
PROT_EXEC 映射区可执行
PROT_NONE 映射区不可访问

flags选项取值

常量 说明
MAP_SHARED 对映射区的修改会写回文件(共享模式)
MAP_PRIVATE 对映射区的修改采用写时复制,不修改源文件(私有模式)
MAP_FIXED 必须将映射区放在addr指定的地址

6.3 munmap函数

#include <sys/mman.h>

int munmap(void *addr, size_t len);
// 返回值:成功返回0,出错返回-1

munmap解除映射关系。进程终止时,所有映射区域会自动解除。

6.4 mmap使用示例

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <string.h>

int main(int argc, char *argv[])
{
    int fd;
    char *map_ptr;
    struct stat sb;
    
    if (argc != 2) {
        fprintf(stderr, "usage: %s <filename>\n", argv[0]);
        exit(1);
    }
    
    /* 打开文件 */
    fd = open(argv[1], O_RDWR);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    
    /* 获取文件大小 */
    if (fstat(fd, &sb) < 0) {
        perror("fstat error");
        exit(1);
    }
    
    /* 将文件映射到内存 */
    map_ptr = mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE, 
                   MAP_SHARED, fd, 0);
    if (map_ptr == MAP_FAILED) {
        perror("mmap error");
        exit(1);
    }
    
    /* 关闭文件描述符(不影响映射区) */
    close(fd);
    
    /* 直接通过内存访问文件内容 */
    printf("文件内容前100字节:\n");
    write(STDOUT_FILENO, map_ptr, (sb.st_size > 100 ? 100 : sb.st_size));
    printf("\n");
    
    /* 修改映射区内容(会写回文件,因为使用的是MAP_SHARED) */
    printf("\n在文件开头写入字符串...\n");
    sprintf(map_ptr, "Hello from mmap! ");
    
    /* 解除映射 */
    if (munmap(map_ptr, sb.st_size) < 0) {
        perror("munmap error");
        exit(1);
    }
    
    return 0;
}

6.5 mmap的优点

  1. 直接操作内存:不使用缓冲区,避免了多余的数据复制
  2. 减少系统调用:访问文件就像访问内存一样,不需要调用read/write
  3. 进程间共享:使用MAP_SHARED时,多个进程可以映射同一文件,实现共享内存

七、异步I/O(概述)

7.1 异步I/O的概念

异步I/O允许进程发起一个I/O操作后立即返回,当I/O操作完成时,进程会收到通知。

在System V中,异步I/O是通过SIGPOLL信号实现的。除了调用ioctl说明产生SIGPOLL信号的条件以外,还应为该信号建立信号处理程序。对于SIGPOLL的默认动作是终止该进程,所以应当在调用ioctl之前建立信号处理程序。

📖 拓展知识:Linux还提供了POSIX异步I/O(AIO)接口,使用aio_readaio_write等函数。与信号驱动的异步I/O不同,POSIX AIO使用专门的异步I/O控制块(aiocb)来管理I/O请求,并提供aio_erroraio_return来获取操作状态。

八、架构图与流程图

8.1 非阻塞I/O与阻塞I/O对比

I/O设备 内核 应用程序 I/O设备 内核 应用程序 阻塞I/O模式 进程阻塞等待 loop [等待数据] 非阻塞I/O模式 轮询重试 alt [数据未就绪] [数据就绪] read()系统调用 发起I/O请求 检查数据就绪 数据就绪 将数据从内核拷贝到用户空间 返回读取的数据 read()系统调用(非阻塞) 发起I/O请求 立即返回EAGAIN 继续其他工作 返回读取的数据

8.2 I/O多路转接架构图

内核

应用程序

传入监控列表

就绪

就绪

就绪

就绪

通知

调用select/poll/epoll

监控多个文件描述符

描述符1
socket/pipe

描述符2
socket/pipe

描述符3
socket/pipe

...

返回就绪列表

8.3 select/poll/epoll比较

渲染错误: Mermaid 渲染失败: Parse error on line 4: ... --> S3[轮询所有fd
O(n)复杂度] end -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

8.4 readv/writev工作示意图

writev

readv

用户空间缓冲区

缓冲区1
iov_base1, iov_len1

缓冲区2
iov_base2, iov_len2

缓冲区3
iov_base3, iov_len3

从文件/套接字
读取数据

按iov顺序
依次填满各缓冲区

按iov顺序
依次从各缓冲区取数据

一次性写入
文件/套接字

8.5 存储映射I/O架构图

磁盘

物理内存

虚拟内存管理

进程地址空间

页映射

代码段

数据段

内存映射区
mmap

虚拟内存页

物理页框

磁盘文件

九、完整代码示例

9.1 使用select实现简单的TCP回显服务器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>

#define PORT 8888
#define MAX_CLIENTS 30
#define BUFFER_SIZE 1024

int main(void)
{
    int master_socket, addrlen, new_socket, client_socket[MAX_CLIENTS];
    int activity, i, valread, sd, max_sd;
    struct sockaddr_in address;
    char buffer[BUFFER_SIZE];
    fd_set readfds;
    
    /* 初始化客户端socket数组 */
    for (i = 0; i < MAX_CLIENTS; i++) {
        client_socket[i] = 0;
    }
    
    /* 创建主socket */
    master_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (master_socket < 0) {
        perror("socket error");
        exit(1);
    }
    
    /* 设置socket选项 */
    int opt = 1;
    if (setsockopt(master_socket, SOL_SOCKET, SO_REUSEADDR, 
                   (char *)&opt, sizeof(opt)) < 0) {
        perror("setsockopt error");
        exit(1);
    }
    
    /* 绑定地址 */
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    
    if (bind(master_socket, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind error");
        exit(1);
    }
    
    /* 监听 */
    if (listen(master_socket, 3) < 0) {
        perror("listen error");
        exit(1);
    }
    
    addrlen = sizeof(address);
    printf("等待连接,端口 %d...\n", PORT);
    
    while (1) {
        /* 清空描述符集 */
        FD_ZERO(&readfds);
        
        /* 添加主socket到描述符集 */
        FD_SET(master_socket, &readfds);
        max_sd = master_socket;
        
        /* 添加客户端socket到描述符集 */
        for (i = 0; i < MAX_CLIENTS; i++) {
            sd = client_socket[i];
            if (sd > 0) {
                FD_SET(sd, &readfds);
            }
            if (sd > max_sd) {
                max_sd = sd;
            }
        }
        
        /* 等待活动 */
        activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);
        if ((activity < 0) && (errno != EINTR)) {
            perror("select error");
        }
        
        /* 检查主socket是否有新连接 */
        if (FD_ISSET(master_socket, &readfds)) {
            new_socket = accept(master_socket, (struct sockaddr *)&address, 
                                (socklen_t *)&addrlen);
            if (new_socket < 0) {
                perror("accept error");
                exit(1);
            }
            
            printf("新连接,socket fd = %d, ip = %s, port = %d\n",
                   new_socket, inet_ntoa(address.sin_addr), 
                   ntohs(address.sin_port));
            
            /* 将新socket加入客户端数组 */
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client_socket[i] == 0) {
                    client_socket[i] = new_socket;
                    break;
                }
            }
        }
        
        /* 检查客户端socket的I/O活动 */
        for (i = 0; i < MAX_CLIENTS; i++) {
            sd = client_socket[i];
            if (FD_ISSET(sd, &readfds)) {
                /* 读取数据 */
                valread = read(sd, buffer, BUFFER_SIZE);
                if (valread == 0) {
                    /* 客户端关闭连接 */
                    getpeername(sd, (struct sockaddr *)&address, 
                                (socklen_t *)&addrlen);
                    printf("主机 %s:%d 断开连接\n",
                           inet_ntoa(address.sin_addr), ntohs(address.sin_port));
                    close(sd);
                    client_socket[i] = 0;
                } else {
                    /* 回显数据 */
                    buffer[valread] = '\0';
                    printf("收到: %s", buffer);
                    write(sd, buffer, strlen(buffer));
                }
            }
        }
    }
    
    close(master_socket);
    return 0;
}

9.2 使用mmap实现进程间共享内存

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>

#define SHM_SIZE 4096

int main(void)
{
    int fd;
    char *shared_mem;
    pid_t pid;
    
    /* 创建共享内存文件(在tmpfs中) */
    fd = open("/dev/shm/myshm", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open error");
        exit(1);
    }
    
    /* 设置文件大小 */
    if (ftruncate(fd, SHM_SIZE) < 0) {
        perror("ftruncate error");
        exit(1);
    }
    
    /* 将文件映射到内存(共享模式) */
    shared_mem = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE,
                      MAP_SHARED, fd, 0);
    if (shared_mem == MAP_FAILED) {
        perror("mmap error");
        exit(1);
    }
    
    /* 关闭文件描述符(映射区仍然有效) */
    close(fd);
    
    /* 初始化共享内存 */
    memset(shared_mem, 0, SHM_SIZE);
    
    pid = fork();
    if (pid < 0) {
        perror("fork error");
        exit(1);
    } else if (pid == 0) {
        /* 子进程:写入数据 */
        sprintf(shared_mem, "Hello from child process! PID = %d", getpid());
        printf("子进程写入: %s\n", shared_mem);
    } else {
        /* 父进程:等待子进程完成,然后读取数据 */
        wait(NULL);
        printf("父进程读取: %s\n", shared_mem);
        
        /* 解除映射 */
        munmap(shared_mem, SHM_SIZE);
        
        /* 删除共享内存文件 */
        unlink("/dev/shm/myshm");
    }
    
    return 0;
}

十、常见问题与注意事项

问题 解决方法
非阻塞I/O导致CPU占用过高 结合select/poll使用,避免空轮询
select返回EINTR错误 检查被信号中断,重新调用select
记录锁无法阻止非合作进程 使用强制性锁(mount时指定-o mand并设置文件权限)
select支持的文件描述符数不够 使用pollepoll,或重新编译内核增大FD_SETSIZE
mmap映射失败 检查文件大小是否为0,offset必须是页对齐的
多进程fork后记录锁不继承 需要在子进程中重新获取锁
readv/writeviovcnt超过限制 分多次调用,或检查IOV_MAX限制

💡 关键要点

  1. 非阻塞I/O:对于低速设备(管道、套接字、终端)才有意义,磁盘文件通常是阻塞的
  2. 记录锁:本质是字节范围锁,使用fcntl实现,建议性锁需要合作进程遵守
  3. I/O多路转接select/poll跨平台性好,epoll性能最优但仅限Linux
  4. readv/writev:通过一次系统调用处理多个不连续缓冲区,减少系统调用次数
  5. 存储映射I/O:直接操作内存访问文件,适合大文件的高效访问

十一、总结

本章系统介绍了UNIX系统中的高级I/O技术,主要包括:

  1. 非阻塞I/O:通过O_NONBLOCK标志使低速设备的I/O操作立即返回,避免进程永久阻塞

  2. 记录锁:使用fcntl函数对文件特定区域进行锁定,实现进程间的互斥访问

  3. I/O多路转接:通过selectpselectpoll函数单进程监控多个描述符,避免为每个描述符创建独立线程/进程

  4. readv和writev:一次性读写多个不连续缓冲区,减少系统调用次数,提高I/O效率

  5. 存储映射I/O:通过mmap将磁盘文件映射到进程地址空间,直接通过内存访问文件

这些高级I/O技术是现代高性能服务器编程的基础。select/poll/epoll的选择与使用、非阻塞I/O与I/O多路转接的结合、mmap的高效文件访问,都是面试和实践中的高频知识点。

十二、下篇预告

第15章《进程间通信》 将介绍:

  • 管道和FIFO(半双工管道)
  • XSI IPC(消息队列、信号量、共享内存)
  • POSIX信号量
  • 记录锁(第14章已介绍,本章将深入应用)
  • 进程间通信的选择策略

本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐