作者: andylin02
学习章节: 第二十二章 信号驱动I/O
关键词: 信号驱动I/O, SIGIO, O_ASYNC, F_SETOWN, fcntl, UDP回射服务器, 异步通知, 信号处理函数
一、章节概述
1.1 本章焦点
第二十二章介绍的是五种I/O模型中的信号驱动式I/O(Signal-Driven I/O)。在前面的章节中,我们讨论了阻塞式I/O、非阻塞式I/O和I/O复用模型。它们虽然各有特点,但都有一个共同点——无论是主动阻塞等待数据,还是通过select轮询,都需要应用程序主动“询问”内核I/O的状态。
信号驱动I/O改变了这种模式:由内核主动通知进程——当描述符就绪时,内核发送SIGIO信号给进程。进程不再需要主动轮询或阻塞等待,只需注册信号处理函数,就可以在I/O事件发生时被动接收通知。这使得进程可以去做其他事情,实现真正的异步通知。
💡 本章核心价值:读完第二十二章,你将能够——
- 深刻理解信号驱动I/O的工作原理及其在五种I/O模型中的定位
- 掌握将套接字设置为信号驱动模式的三个关键步骤
- 使用SIGIO信号和信号处理函数编写UDP回射服务器
- 理解UDP与TCP在信号驱动I/O上的行为差异
- 了解信号驱动I/O在实际工程中的局限性
1.2 本章内容结构
| 22.1 | 概述 | 信号驱动I/O在五种I/O模型中的位置 |
| 22.2 | 套接口上的信号驱动I/O | 设置套接字为信号驱动模式的三个步骤 |
| 22.3 | 使用SIGIO的UDP回射服务器程序 | 完整示例:SIGIO驱动的UDP回射服务器 |
| 22.4 | 小结 | 信号驱动I/O的优缺点与适用场景 |
| 22.5 | 习题 | 课后思考题 |
二、五种I/O模型回顾
2.1 信号驱动I/O在五种模型中的位置
在《UNIX网络编程》第6章中,Stevens总结了五种I/O模型。下表回顾了这五种模型的本质区别:
| 阻塞式I/O | 进程阻塞等待 | 进程阻塞拷贝 | 操作完成才返回 |
| 非阻塞式I/O | 立即返回(轮询) | 进程阻塞拷贝 | 轮询检查 |
| I/O复用(select/poll) | 阻塞于select | 进程阻塞拷贝 | select返回通知 |
| 信号驱动I/O | 不阻塞,内核就绪时发信号 | 进程阻塞拷贝 | SIGIO信号通知 |
| 异步I/O | 不阻塞 | 不阻塞(内核完成拷贝后通知) | 完成时通知 |
2.2 信号驱动I/O vs 异步I/O的核心区别
信号驱动I/O常被误认为是异步I/O,但两者有本质区别:
| 内核通知的时机 | 数据已就绪,可以启动I/O操作 | I/O操作已完成(数据已拷贝到用户空间) |
| 应用程序仍需调用I/O函数 | ✅ 需要(收到SIGIO后调用recvfrom) | ❌ 不需要(数据已在用户缓冲区) |
| 数据拷贝阶段是否阻塞 | ✅ 阻塞(recvfrom调用) | ❌ 不阻塞(内核完成拷贝后通知) |
💡 关键理解:信号驱动I/O的通知内容是“数据准备好了,你可以来读了”;异步I/O的通知内容是“数据已经帮你读好了,你可以直接用了”。正因如此,信号驱动I/O本质上仍属于同步I/O范畴。
2.3 信号驱动I/O的独特优势
与I/O复用(select和poll)相比,信号驱动I/O的优势在于:免去了select的阻塞与轮询,当有活跃套接字时,由注册的handler直接处理。在需要处理大量描述符但活跃连接较少的场景中,信号驱动I/O可以避免每次都要遍历所有描述符的开销。
三、套接口上的信号驱动I/O
3.1 三个基本步骤
为使一个套接字能够使用信号驱动I/O,需要执行以下三个步骤:
| ① | 建立SIGIO信号处理程序 | signal() 或 sigaction() | 注册处理SIGIO信号的函数 |
| ② | 设置套接字属主 | fcntl 的 F_SETOWN 命令 | 指定接收SIGIO信号的进程或进程组 |
| ③ | 激活信号驱动I/O | fcntl 的 F_SETFL 命令 | 设置 O_ASYNC 文件状态标志 |
3.2 设置套接字属主
套接字属主指定了当套接字上有事件发生时,内核应该将信号发送给哪个进程或进程组。
// 将当前进程设置为套接字的属主
fcntl(sockfd, F_SETOWN, getpid());
3.3 激活信号驱动I/O
设置 O_ASYNC 标志后,内核会在套接字就绪时发送SIGIO信号。通常需要与 O_NONBLOCK 标志一起设置,以便在信号处理函数中可以非阻塞地循环读取数据。
int flags;
flags = fcntl(sockfd, F_GETFL, 0);
flags |= O_NONBLOCK | O_ASYNC;
fcntl(sockfd, F_SETFL, flags);
3.4 SIGIO信号的产生条件
UDP套接字:在UDP上,SIGIO信号会在以下两个事件时产生:
TCP套接字:SIGIO信号在TCP上的产生条件较为复杂,因此信号驱动I/O模型在实际应用中主要用在UDP套接字上,在TCP套接字上几乎没有什么使用。
四、完整源代码示例
4.1 使用SIGIO的UDP回射服务器
以下是一个使用SIGIO信号驱动的UDP回射服务器程序。程序启动后,主循环进入睡眠状态(或执行其他任务),当数据报到达时,内核发送SIGIO信号,信号处理函数负责接收数据并回射。
#include "unp.h"
#include <sys/ioctl.h>
static int sockfd;
// 环形缓冲区数据结构
#define MAXDATAGRAM 8192
#define MAXDGRAMQUEUE 64
struct dg_packet {
struct sockaddr_storage sa;
socklen_t salen;
char buf[MAXDATAGRAM];
};
static struct dg_packet queue[MAXDGRAMQUEUE];
static volatile int quelen = 0; // 当前队列中的数据报数量
static volatile int qput = 0;
static volatile int qget = 0;
static volatile int queuedatagrams = 0;
// 信号处理函数
void sig_io(int signo)
{
int n;
struct sockaddr_storage cliaddr;
socklen_t clilen;
char buf[MAXDATAGRAM];
for ( ; ; ) {
clilen = sizeof(cliaddr);
// 非阻塞读取,避免信号不排队导致的数据丢失
n = recvfrom(sockfd, buf, MAXDATAGRAM, MSG_DONTWAIT,
(SA *)&cliaddr, &clilen);
if (n < 0) {
if (errno == EWOULDBLOCK)
break; // 没有更多数据可读
else
err_sys("recvfrom error");
}
// 将数据报存入环形缓冲区
if (queuedatagrams >= MAXDGRAMQUEUE)
err_quit("queue overflow");
queue[qput].salen = clilen;
memcpy(&queue[qput].sa, &cliaddr, clilen);
memcpy(queue[qput].buf, buf, n);
queue[qput].buf[n] = 0;
qput = (qput + 1) % MAXDGRAMQUEUE;
queuedatagrams++;
}
}
int main(int argc, char **argv)
{
struct sockaddr_in servaddr;
int on = 1;
// 1. 创建UDP套接字
sockfd = Socket(AF_INET, SOCK_DGRAM, 0);
// 2. 绑定服务器地址
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(sockfd, (SA *)&servaddr, sizeof(servaddr));
// 3. 设置非阻塞模式(防止信号不排队时阻塞)
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
// 4. 建立SIGIO信号处理程序
Signal(SIGIO, sig_io);
// 5. 设置套接字属主为当前进程
Fcntl(sockfd, F_SETOWN, getpid());
// 6. 激活信号驱动I/O(设置O_ASYNC标志)
flags = fcntl(sockfd, F_GETFL, 0);
Fcntl(sockfd, F_SETFL, flags | O_ASYNC);
// 7. 主循环:处理队列中的数据报
for ( ; ; ) {
while (queuedatagrams == 0)
pause(); // 无数据时进入睡眠等待SIGIO信号
// 处理队列中的一个数据报
Sendto(sockfd, queue[qget].buf, strlen(queue[qget].buf), 0,
(SA *)&queue[qget].sa, queue[qget].salen);
qget = (qget + 1) % MAXDGRAMQUEUE;
queuedatagrams—;
}
}
4.2 代码说明
| 非阻塞recvfrom | 信号处理函数中使用MSG_DONTWAIT标志,防止信号不排队时阻塞 |
| 环形缓冲区 | 使用队列存储多个数据报,避免信号处理函数处理时间过长 |
| 主循环睡眠等待 | 无数据时pause(),收到SIGIO信号后唤醒处理队列中的数据 |
| 原子操作volatile | 队列指针和计数使用volatile修饰,确保编译器不会优化掉对内存的访问 |
4.3 简化的UDP时间服务器示例
以下是一个使用SIGIO信号驱动I/O实现的简化版UDP时间服务器,完整演示了三个关键步骤:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#define PORT 9999
#define MAXLINE 1024
static int sockfd;
void sig_io_handler(int signo)
{
char buf[MAXLINE];
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
time_t ticks;
// 接收客户端请求
if (recvfrom(sockfd, buf, MAXLINE, 0, (struct sockaddr *)&cliaddr, &clilen) > 0) {
ticks = time(NULL);
snprintf(buf, sizeof(buf), "%.24s\\r\\n", ctime(&ticks));
sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&cliaddr, clilen);
printf("Served client: %s:%d\\n",
inet_ntoa(cliaddr.sin_addr), ntohs(cliaddr.sin_port));
}
}
int main()
{
struct sockaddr_in servaddr;
// ① 创建UDP套接字并绑定
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(PORT);
bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
// ② 建立SIGIO信号处理程序
signal(SIGIO, sig_io_handler);
// ③ 设置套接字属主
fcntl(sockfd, F_SETOWN, getpid());
// ④ 激活信号驱动I/O
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_ASYNC);
printf("UDP time server started on port %d\\n", PORT);
// ⑤ 主循环:只需等待信号
for ( ; ; )
pause();
return 0;
}
五、关键图表
5.1 信号驱动I/O工作流程图
┌─────────────────────────────────────────────────────────────────────────────┐
│ 信号驱动I/O(Signal-Driven I/O)工作流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 进程 内核 │
│ │ │ │
│ │ ① 安装SIGIO信号处理程序 │ │
│ │ ② fcntl(F_SETOWN) 设置套接字属主 │ │
│ │ ③ fcntl(F_SETFL | O_ASYNC) 激活信号驱动I/O │ │
│ │────────────────────────────────────────────────────────→│ │
│ │ │ │
│ │ │ 数据尚未到达 │
│ │ │ │
│ │ ④ 进程继续执行其他任务 │ │
│ │ │ │
│ │ │ ⑤ 数据到达 │
│ │ │ │
│ │ ⑥ SIGIO信号(就绪通知) │ │
│ │←────────────────────────────────────────────────────────│ │
│ │ │ │
│ │ ⑦ 信号处理函数中调用recvfrom │ │
│ │────────────────────────────────────────────────────────→│ │
│ │ │ │
│ │ │ ⑧ 拷贝数据 │
│ │ │ │
│ │ ⑨ recvfrom返回 │ │
│ │←────────────────────────────────────────────────────────│ │
│ │ │ │
│ │ ⑩ 信号处理函数返回,进程继续执行 │ │
│ │ │ │
│ │ 进程不被阻塞 │
│ │
│ 💡 内核在数据就绪时主动通知进程,而非进程主动询问 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
5.2 UDP信号驱动I/O时序图
┌─────────────────────────────────────────────────────────────────────────────┐
│ UDP信号驱动I/O服务器 – 完整时序图 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 主进程 信号处理函数 内核 │
│ │ │ │ │
│ │ socket() │ │ │
│ │───────────────────────────┼───────────────────────────→│ │
│ │ │ │ │
│ │ bind() │ │ │
│ │───────────────────────────┼───────────────────────────→│ │
│ │ │ │ │
│ │ signal(SIGIO, sig_io) │ │ │
│ │───────────────────────────→│ │ │
│ │ │ (安装信号处理函数) │ │
│ │ │ │ │
│ │ fcntl(F_SETOWN, pid) │ │ │
│ │───────────────────────────┼───────────────────────────→│ │
│ │ │ │ │
│ │ fcntl(F_SETFL, O_ASYNC) │ │ │
│ │───────────────────────────┼───────────────────────────→│ │
│ │ │ │ │
│ │ pause() / 主循环 │ │ │
│ │ │ │ │
│ │ │ │ 数据报到达 │
│ │ │ │ │
│ │ │ SIGIO信号 ───────→│ │
│ │ (进程被唤醒) │ │ │
│ │←──────────────────────────│ │ │
│ │ │ │ │
│ │ (在信号处理上下文中执行) │ │ │
│ │ │ │ │
│ │ recvfrom() ─────────────────→│ │
│ │ │ │ │
│ │ ←───────────────────────── 数据│ │
│ │ │ │ │
│ │ sendto() ───────────────────→│ │
│ │ │ │ │
│ │ (信号处理函数返回) │ │ │
│ │ │ │ │
│ │ 继续循环或再次pause() │ │ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
六、UDP vs TCP:信号驱动I/O的差异
| UDP套接字 | 数据报到达;异步错误发生 | ✅ 常用,简单清晰 |
| TCP套接字(监听) | 新连接到达(accept就绪) | ⚠️ 可用但较少用 |
| TCP套接字(已连接) | 多种复杂情况(数据到达、连接断开、带外数据等) | ❌ 几乎不用,条件复杂 |
💡 为什么TCP上很少使用信号驱动I/O?
信号驱动I/O在TCP上的应用存在诸多限制:首先,TCP是字节流协议,数据到达的边界不明确;其次,SIGIO信号在TCP上可能由多种原因触发(普通数据到达、带外数据到达、连接断开、错误发生等),信号处理函数需要处理多种情况,增加了复杂度;再者,Linux信号不排队,多个事件可能合并为一个信号,容易导致数据丢失。因此在实际工程中,TCP上的高并发通常使用I/O复用(select/poll/epoll)而非信号驱动I/O。
七、常见问题与注意事项
7.1 常见错误速查表
| 收不到SIGIO信号 | 未设置套接字属主 | 调用fcntl(F_SETOWN, getpid()) |
| SIGIO信号只触发一次 | 未持续设置O_ASYNC标志 | 确认fcntl设置了O_ASYNC |
| 数据报丢失 | Linux信号不排队 | 在信号处理函数中使用非阻塞循环读取,直到EWOULDBLOCK |
| 程序收到SIGIO后崩溃 | 信号处理函数中调用了非异步安全函数 | 只调用异步信号安全函数,或将数据放入队列由主循环处理 |
| TCP上SIGIO混乱 | 触发条件过多,难以区分 | 改用I/O复用方案 |
7.2 编程注意事项
| 信号不排队 | Linux默认不将SIGIO信号排队,多个事件可能合并为一个信号。必须在信号处理函数中循环读取所有待处理数据 |
| 异步信号安全 | 信号处理函数中只能调用异步信号安全的函数(如recvfrom、sendto、write),不可调用printf等非安全函数 |
| 数据队列 | 最佳实践是在信号处理函数中将数据放入队列,由主循环处理 |
| TCP场景谨慎 | 信号驱动I/O在TCP上几乎没有什么使用,如需在TCP上使用,需谨慎处理 |
| 端口绑定时间 | 设置O_ASYNC之前,必须先设置套接字属主F_SETOWN |
7.3 与其他模型的对比
| 阻塞式I/O | 低 | 简单程序、低并发 |
| I/O复用(select/epoll) | 中 | 高并发TCP服务器 |
| 信号驱动I/O | 高 | UDP服务器、对响应延迟敏感的场景 |
| 异步I/O | 高 | 高性能文件I/O、特殊网络场景 |
💡 实际应用现状:信号驱动I/O模型在实际中并不常用。其主要原因包括:信号不排队的限制、在TCP上行为复杂、以及epoll/kqueue等更高效的事件驱动机制的普及。但对于UDP服务器,信号驱动I/O仍是一种简洁有效的实现方式。
八、本章小结
8.1 核心知识点回顾
| 信号驱动I/O本质 | 内核在描述符就绪时发送SIGIO信号,主动通知进程 |
| 三个关键步骤 | 建立信号处理程序 → 设置套接字属主(F_SETOWN)→ 激活信号驱动I/O(O_ASYNC) |
| UDP vs TCP | UDP上SIGIO产生条件简单清晰,是主要应用场景;TCP上产生条件复杂,几乎不使用 |
| 信号不排队 | Linux中SIGIO信号不排队,必须在信号处理函数中循环读取所有数据 |
| 信号处理函数限制 | 只能调用异步信号安全的函数,推荐将数据放入队列由主循环处理 |
| vs异步I/O | 信号驱动I/O通知“数据就绪”(仍需应用读取);异步I/O通知“操作完成”(无需应用读取) |
8.2 本章思维导图
第二十二章 信号驱动I/O
├── I/O模型定位
│ ├── 五种I/O模型中的第四种
│ ├── 数据就绪时由内核主动发送SIGIO信号
│ └── vs 异步I/O:通知时机不同(就绪 vs 完成)
├── 三个关键步骤
│ ├── ① 建立SIGIO信号处理程序(signal/sigaction)
│ ├── ② 设置套接字属主(fcntl F_SETOWN)
│ └── ③ 激活信号驱动I/O(fcntl F_SETFL | O_ASYNC)
├── UDP套接字使用
│ ├── 数据报到达时产生SIGIO
│ ├── 异步错误时产生SIGIO
│ └── 主要应用场景
├── TCP套接字使用
│ ├── 监听套接字:新连接到达时产生
│ ├── 已连接套接字:多种复杂情况
│ └── 实际中几乎不使用
├── 编程注意事项
│ ├── 信号不排队 → 循环非阻塞读取
│ ├── 异步信号安全 → 避免printf等非安全函数
│ ├── 数据队列设计 → 信号处理函数只入队,主循环出队处理
│ └── UDP服务器中设置O_NONBLOCK
└── 实践示例
├── UDP回射服务器(SIGIO驱动)
└── UDP时间服务器(简化版)
九、下一章预告
📌 下一篇:《UNIX网络编程》读书笔记(二十三):第二十三章 线程
第二十三章将详细讲解:
- pthread_create:创建新线程
- pthread_join:等待线程终止
- pthread_self:获取自身线程ID
- pthread_detach:分离线程
- pthread_exit:线程终止
- 互斥锁(Mutex):保护临界区
- 条件变量(Condition Variable):线程间事件通知
- 读写锁(RWLock):读共享、写独占
- 多线程TCP回射服务器
- 线程池模型的设计与实现
- 线程与I/O复用的结合
学习目标:学完第二十三章后,你将能够——
- 使用POSIX线程API编写多线程网络程序
- 使用互斥锁和条件变量解决线程间同步问题
- 实现线程池模型,提高服务器的并发处理能力
- 编写线程安全的TCP并发服务器
敬请期待!
参考资料
本文为个人学习笔记,仅用于知识分享。如有错误,欢迎指正。
👍🏻 点赞 + 收藏 + 分享,让更多开发者看到这篇深度解析!❤️ 如果觉得有用,请给个赞支持一下作者!


