《UNIX环境高级编程》读书笔记15: 高级I/O
学习目标:掌握UNIX系统中的高级I/O技术,包括非阻塞I/O、记录锁、I/O多路转接、readv/writev、存储映射I/O等,理解这些技术在多线程和高性能服务器编程中的应用场景。本章是APUE中承上启下的关键章节。第11-13章讲解了线程与守护进程,第15章开始将介绍进程间通信(IPC),而第14章的高级I/O技术为这些后续章节提供了重要的技术基础。非阻塞I/O:让低速设备的I/O操作不会永
作者: 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多路转接:
select、pselect、poll函数,实现单进程监控多个文件描述符 - 异步I/O:进程在I/O操作完成时收到通知
readv和writev函数:在一次函数调用中读写多个非连续缓冲区- 存储映射I/O(
mmap):将磁盘文件映射到内存空间
💡 学习建议:本章内容较多但逻辑清晰,核心思想是“如何让I/O操作更高效”。建议结合服务器编程场景理解——非阻塞I/O与I/O多路转接是高性能网络编程的核心技术,
select/poll/epoll的选择对比是面试常考题,值得深入理解。
二、非阻塞I/O
2.1 低速系统调用
对低速设备的I/O操作可能会使进程永久阻塞,这类系统调用主要有以下情况:
- 如果数据并不存在,则读文件可能会使调用者永远阻塞(例如读管道、终端设备和网络设备)
- 如果数据不能立即被接受,则写这些同样的文件也会使调用者永远阻塞
- 在某些条件发生之前,打开文件会被阻塞(例如以只写方式打开一个FIFO,那么在没有其他进程已用读方式打开该FIFO时)
- 对已经加上强制性锁的文件进行读、写
- 某些ioctl操作
- 某些进程间通信函数
📖 拓展知识:低速系统调用(slow system call)是指那些可能会使进程永远阻塞的系统调用。需要注意,与磁盘I/O相关的系统调用通常不被认为是低速的,因为磁盘I/O虽然慢但终究会返回,而管道、终端设备和网络设备的读/写可能无限期等待。
2.2 非阻塞I/O的本质
非阻塞I/O使我们可以调用open、read和write这样的I/O操作,并使这些操作不会永远阻塞。如果这种操作不能完成,则调用立即出错返回,表示该操作如继续执行将阻塞。
非阻塞I/O与普通I/O的对比:
| 特性 | 普通(阻塞)I/O | 非阻塞I/O |
|---|---|---|
| 操作无法立即完成时 | 进程阻塞等待 | 立即返回错误(EAGAIN或EWOULDBLOCK) |
| CPU使用 | 等待期间CPU空闲 | 轮询时CPU被持续占用 |
| 适用场景 | 单描述符、确定有数据的场景 | 多描述符、不确定哪个先准备好的场景 |
2.3 设置非阻塞标志的方法
对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:
- 创建时指定:如果调用
open获得描述符,则可指定O_NONBLOCK标志 - 运行时修改:对于已经打开的一个描述符,则可调用
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设置为EACCES或EAGAIN |
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 记录锁的隐含继承与释放
关于记录锁的自动继承和释放有以下三条重要规则:
- 锁与进程和文件相关联:当一个进程终止时,它所建立的锁全部释放;无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放
- fork不继承锁:由
fork产生的子进程不继承父进程所设置的锁 - exec继承锁:在执行
exec后,新程序可以继承原执行程序的锁
3.5 建议性锁与强制性锁
| 锁类型 | 说明 |
|---|---|
| 建议性锁 | 内核不强制检查锁,只是为合作进程提供一种互斥机制。如果一个进程不遵循锁规则,仍然可以访问被锁的文件区域 |
| 强制性锁 | 内核会对每一个open、read和write进行检查,验证调用进程是否违背了正在访问的文件上的锁。如果违背,内核会阻塞该操作直到锁被释放 |
📖 开启强制性锁:对一个特定文件打开其设置组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:最大描述符编号值加1readfds:关心的可读描述符集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函数
poll与select类似,但不是为每个状态构造一个描述集,而是构造了一个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多路复用机制,相较于select和poll在大量并发连接场景下性能更优。
| 特性 | select | poll | epoll |
|---|---|---|---|
| 支持的文件描述符数 | 有限(通常1024) | 较大 | 很大(系统上限) |
| 时间复杂度 | O(n) | O(n) | O(1) |
| 工作模式 | 水平触发 | 水平触发 | 水平触发/边缘触发 |
| 用户态到内核态拷贝 | 每次调用都拷贝 | 每次调用都拷贝 | 注册时拷贝一次 |
| 适用场景 | 小规模应用 | 中等规模 | 大规模高并发 |
💡 设计建议:
select和poll适合连接数量小、活跃数量多、实时性要求高的情况。epoll适合客户端的连接数量很大、活跃数量小的情况。在高速LAN环境下所有socket都很活跃时,epoll并不比select/poll有明显优势。
五、readv和writev函数
5.1 函数简介
readv和writev函数允许我们只通过一个函数调用,完成读取数据到不连续的缓存或者从不连续的缓存中向外写数据。这些操作被称作散布读(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的优点
- 直接操作内存:不使用缓冲区,避免了多余的数据复制
- 减少系统调用:访问文件就像访问内存一样,不需要调用
read/write - 进程间共享:使用
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_read、aio_write等函数。与信号驱动的异步I/O不同,POSIX AIO使用专门的异步I/O控制块(aiocb)来管理I/O请求,并提供aio_error和aio_return来获取操作状态。
八、架构图与流程图
8.1 非阻塞I/O与阻塞I/O对比
8.2 I/O多路转接架构图
8.3 select/poll/epoll比较
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工作示意图
8.5 存储映射I/O架构图
九、完整代码示例
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支持的文件描述符数不够 |
使用poll或epoll,或重新编译内核增大FD_SETSIZE |
mmap映射失败 |
检查文件大小是否为0,offset必须是页对齐的 |
多进程fork后记录锁不继承 |
需要在子进程中重新获取锁 |
readv/writev的iovcnt超过限制 |
分多次调用,或检查IOV_MAX限制 |
💡 关键要点:
- 非阻塞I/O:对于低速设备(管道、套接字、终端)才有意义,磁盘文件通常是阻塞的
- 记录锁:本质是字节范围锁,使用
fcntl实现,建议性锁需要合作进程遵守- I/O多路转接:
select/poll跨平台性好,epoll性能最优但仅限Linux- readv/writev:通过一次系统调用处理多个不连续缓冲区,减少系统调用次数
- 存储映射I/O:直接操作内存访问文件,适合大文件的高效访问
十一、总结
本章系统介绍了UNIX系统中的高级I/O技术,主要包括:
-
非阻塞I/O:通过
O_NONBLOCK标志使低速设备的I/O操作立即返回,避免进程永久阻塞 -
记录锁:使用
fcntl函数对文件特定区域进行锁定,实现进程间的互斥访问 -
I/O多路转接:通过
select、pselect、poll函数单进程监控多个描述符,避免为每个描述符创建独立线程/进程 -
readv和writev:一次性读写多个不连续缓冲区,减少系统调用次数,提高I/O效率
-
存储映射I/O:通过
mmap将磁盘文件映射到进程地址空间,直接通过内存访问文件
这些高级I/O技术是现代高性能服务器编程的基础。select/poll/epoll的选择与使用、非阻塞I/O与I/O多路转接的结合、mmap的高效文件访问,都是面试和实践中的高频知识点。
十二、下篇预告
第15章《进程间通信》 将介绍:
- 管道和FIFO(半双工管道)
- XSI IPC(消息队列、信号量、共享内存)
- POSIX信号量
- 记录锁(第14章已介绍,本章将深入应用)
- 进程间通信的选择策略
本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)