第9章 高级套接字编程
高级特性
一、导读
9.1 本章学习目标
第9章进入网络编程的高级领域,探讨那些不常用但极其重要的特性。这些特性是构建复杂、高性能网络应用的基础——从非阻塞I/O到带外数据,从散列/聚集I/O到描述符传递。掌握这些高级特性,你的网络编程能力将从"能用"提升到"精通"。
本章的核心学习目标包括:
- 理解非阻塞I/O的工作原理:如何将套接字设为非阻塞?非阻塞模式下的编程模型是什么?
- 掌握I/O多路复用的高级用法:如何结合非阻塞I/O和epoll构建高性能异步服务器?
- 理解散列/聚集I/O:readv/writev如何减少系统调用次数?
- 了解带外数据(OOB):TCP的紧急机制是什么?如何使用?
- 掌握描述符传递:如何在进程间通过UNIX域套接字传递文件描述符?
- 理解SO_PEERCRED等安全选项:如何验证对端进程的身份?
9.2 为什么本章如此重要
基础的网络编程知识能让你写出"能工作"的程序,但高级特性让你写出"优秀"的程序。
非阻塞I/O + I/O多路复用 是现代高性能服务器的核心架构。Nginx、Redis、Node.js都基于这种模式。理解非阻塞I/O,你才能理解这些系统的设计哲学。
散列/聚集I/O 在处理复杂数据结构时非常有用——比如发送一个HTTP响应,头部和数据体可能存储在不同的缓冲区中,writev可以一次系统调用发送所有数据,减少上下文切换。
描述符传递 是实现进程间资源共享的强大机制。例如,一个进程打开文件,将描述符传递给另一个进程,避免重复打开的开销。
9.3 核心问题引导
在阅读本章时,请带着以下问题思考:
非阻塞I/O和I/O多路复用是什么关系?为什么它们通常一起使用?
在非阻塞模式下,read 返回 EAGAIN 是什么意思?应该如何处理?
writev 相比多次 write 有什么优势?它能保证原子性吗?
TCP的带外数据(OOB)实际应用中有什么用途?为什么很少被使用?
描述符传递的底层机制是什么?为什么只能通过UNIX域套接字传递?
如何在不使用认证的情况下验证对端进程的身份?
二、核心概念详解
9.1 非阻塞I/O
9.1.1 设置非阻塞模式
#include <fcntl.h>
// 方法1:使用fcntl
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
// 方法2:创建时指定(Linux 2.6.28+)
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);9.1.2 非阻塞I/O的行为
默认情况下,套接字是阻塞的——read 在没有数据时会阻塞等待,write 在缓冲区满时会阻塞等待。设为非阻塞后,这些调用会立即返回:
| 操作 | 阻塞模式 | 非阻塞模式 |
|---|---|---|
| read(无数据) | 阻塞等待 | 立即返回-1,errno=EAGAIN |
| read(有数据) | 返回数据 | 返回数据 |
| write(缓冲区未满) | 写入数据 | 写入数据 |
| write(缓冲区满) | 阻塞等待 | 立即返回-1,errno=EAGAIN |
| accept(无连接) | 阻塞等待 | 立即返回-1,errno=EAGAIN |
| connect(进行中) | 阻塞等待 | 立即返回-1,errno=EINPROGRESS |
9.1.3 非阻塞connect
// 设置非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
int ret = connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
if (ret < 0) {
if (errno == EINPROGRESS) {
// 连接正在进行中(三次握手尚未完成)
// 使用select/poll/epoll等待连接完成
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(sockfd, &wfds);
struct timeval tv = {5, 0}; // 5秒超时
int sel = select(sockfd + 1, NULL, &wfds, NULL, &tv);
if (sel > 0) {
int error;
socklen_t len = sizeof(error);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len);
if (error == 0) {
printf("Connected!\n");
} else {
printf("Connect failed: %s\n", strerror(error));
}
} else if (sel == 0) {
printf("Connect timeout\n");
close(sockfd);
} else {
perror("select");
}
} else {
perror("connect");
}
}9.1.4 非阻塞I/O + epoll
非阻塞I/O通常与I/O多路复用结合使用,构建高性能异步服务器:
// 设置非阻塞
int flags = fcntl(connfd, F_GETFL, 0);
fcntl(connfd, F_SETFL, flags | O_NONBLOCK);
// 注册到epoll(边缘触发模式)
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
// 事件循环
while (1) {
int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nready; i++) {
int fd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
// 必须循环读取直到EAGAIN
char buf[4096];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == -1 && errno == EAGAIN) {
break; // 数据读完
} else if (n == 0) {
// 连接关闭
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
break;
} else {
// 错误
break;
}
}
}
if (events[i].events & EPOLLOUT) {
// 可以写入数据
// 循环写入直到EAGAIN
}
}
}9.2 散列/聚集I/O
9.2.1 writev(聚集写)
#include <sys/uio.h>
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
struct iovec {
void *iov_base; // 缓冲区起始地址
size_t iov_len; // 缓冲区长度
};writev 将多个缓冲区的数据一次性写入文件描述符。
// 发送HTTP响应(头部+数据体在不同缓冲区)
char header[] = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\n";
char body[] = "Hello, World!";
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = strlen(header);
iov[1].iov_base = body;
iov[1].iov_len = strlen(body);
writev(sockfd, iov, 2);
// 等价于:
// write(sockfd, header, strlen(header));
// write(sockfd, body, strlen(body));
// 但writev只有一次系统调用开销9.2.2 readv(散列读)
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);readv 将读取的数据分散到多个缓冲区中。
// 读取固定格式的协议消息
// 消息格式:[4字节类型][4字节长度][数据]
uint32_t type, length;
char data[1024];
struct iovec iov[3];
iov[0].iov_base = &type;
iov[0].iov_len = sizeof(type);
iov[1].iov_base = &length;
iov[1].iov_len = sizeof(length);
iov[2].iov_base = data;
iov[2].iov_len = sizeof(data);
// 注意:需要先读取type和length,再根据length读取data
// readv适用于各部分长度已知的场景9.2.3 优势
- 减少系统调用次数:一次writev代替多次write,减少用户态/内核态切换
- 原子性:writev保证所有数据作为一个整体写入(在管道/FIFO中尤其重要)
- 效率:减少内核数据拷贝次数
9.3 带外数据(OOB)
9.3.1 概念
TCP提供了带外数据(Out-Of-Band data)机制,允许紧急数据优先于普通数据传输。但TCP的OOB机制非常有限:
- 只能传输1字节的OOB数据
- 不保证可靠传输
- 实现复杂,不同系统行为不一致
- 大多数应用不使用OOB
9.3.2 发送OOB数据
// 发送带外数据
char oob_byte = 0xFF;
send(sockfd, &oob_byte, 1, MSG_OOB);9.3.3 接收OOB数据
// 方式1:使用MSG_OOB标志
char oob_byte;
recv(sockfd, &oob_byte, 1, MSG_OOB);
// 方式2:使用SIGURG信号
// 当OOB数据到达时,内核发送SIGURG信号
signal(SIGURG, sigurg_handler);
// 需要先设置套接字的所有者
fcntl(sockfd, F_SETOWN, getpid());
void sigurg_handler(int sig) {
char oob_byte;
recv(sockfd, &oob_byte, 1, MSG_OOB);
printf("OOB data: 0x%02x\n", oob_byte);
}
// 方式3:使用SO_OOBINLINE选项
int oobinline = 1;
setsockopt(sockfd, SOL_SOCKET, SO_OOBINLINE, &oobinline, sizeof(oobinline));
// OOB数据被放入正常数据流中,可以用普通read接收
// 使用sockatmark()判断是否在紧急标记处9.3.4 为什么不推荐使用OOB
- 只有1字节,实用性差
- 不同系统实现不一致
- 调试困难
- 替代方案:在应用层定义优先级,或使用多个连接
9.4 描述符传递
9.4.1 概念
通过UNIX域套接字,一个进程可以将打开的文件描述符传递给另一个进程。接收进程可以使用这个描述符读写文件,即使它没有权限打开这个文件。
9.4.2 发送描述符
#include <sys/socket.h>
// 发送描述符的辅助函数
int send_fd(int sock, int fd) {
struct msghdr msg = {0};
char buf[1] = {0};
struct iovec iov = {
.iov_base = buf,
.iov_len = 1
};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
// 构造控制消息
union {
struct cmsghdr cm;
char control[CMSG_SPACE(sizeof(int))];
} control_un;
memset(&control_un, 0, sizeof(control_un));
msg.msg_control = control_un.control;
msg.msg_controllen = sizeof(control_un.control);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
*((int *)CMSG_DATA(cmsg)) = fd;
return sendmsg(sock, &msg, 0);
}9.4.3 接收描述符
// 接收描述符的辅助函数
int recv_fd(int sock) {
struct msghdr msg = {0};
char buf[1];
struct iovec iov = {
.iov_base = buf,
.iov_len = 1
};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
union {
struct cmsghdr cm;
char control[CMSG_SPACE(sizeof(int))];
} control_un;
memset(&control_un, 0, sizeof(control_un));
msg.msg_control = control_un.control;
msg.msg_controllen = sizeof(control_un.control);
if (recvmsg(sock, &msg, 0) <= 0) {
return -1;
}
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
if (cmsg == NULL || cmsg->cmsg_level != SOL_SOCKET ||
cmsg->cmsg_type != SCM_RIGHTS) {
return -1;
}
return *((int *)CMSG_DATA(cmsg));
}9.4.4 使用示例
// 进程A:打开文件并传递描述符
int fd = open("data.txt", O_RDONLY);
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
if (fork() == 0) {
// 子进程:接收描述符
close(sv[0]);
int received_fd = recv_fd(sv[1]);
char buf[1024];
read(received_fd, buf, sizeof(buf));
printf("Read: %s\n", buf);
close(received_fd);
close(sv[1]);
} else {
// 父进程:发送描述符
close(sv[1]);
send_fd(sv[0], fd);
close(fd);
close(sv[0]);
}9.5 SO_PEERCRED:获取对端进程信息
9.5.1 概念
在UNIX域套接字上,可以使用 SO_PEERCRED 获取对端进程的PID、UID和GID。这对于验证对端身份非常有用。
9.5.2 使用方法
#include <sys/socket.h>
struct ucred cred;
socklen_t len = sizeof(cred);
if (getsockopt(connfd, SOL_SOCKET, SO_PEERCRED, &cred, &len) == 0) {
printf("Peer PID: %d\n", cred.pid);
printf("Peer UID: %d\n", cred.uid);
printf("Peer GID: %d\n", cred.gid);
// 验证对端是否是root
if (cred.uid == 0) {
printf("Peer is root\n");
}
}应用场景:
- 本地服务验证:确保只有特定用户/组的进程可以连接
- 安全审计:记录哪个进程发起了连接
- 权限控制:根据对端UID决定是否提供服务
三、实战案例
9.1 非阻塞echo服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <errno.h>
#define MAX_EVENTS 64
#define MAX_BUF 4096
void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(int argc, char **argv) {
int port = 8080;
if (argc > 1) port = atoi(argv[1]);
int listenfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
int optval = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
struct sockaddr_in servaddr = {
.sin_family = AF_INET,
.sin_addr.s_addr = htonl(INADDR_ANY),
.sin_port = htons(port)
};
bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listenfd, 128);
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
struct epoll_event events[MAX_EVENTS];
for (;;) {
int nready = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nready < 0) {
if (errno == EINTR) continue;
break;
}
for (int i = 0; i < nready; i++) {
if (events[i].data.fd == listenfd) {
while (1) {
struct sockaddr_in cliaddr;
socklen_t clilen = sizeof(cliaddr);
int connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
if (connfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
break;
}
set_nonblocking(connfd);
ev.events = EPOLLIN | EPOLLOUT | EPOLLET;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}
} else {
int fd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
char buf[MAX_BUF];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 简单回显:直接写入(实际应使用缓冲区管理)
write(fd, buf, n);
} else if (n == -1 && errno == EAGAIN) {
break;
} else {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
}
}
}
}
}
return 0;
}9.2 writev发送HTTP响应
#include <sys/uio.h>
#include <string.h>
void send_http_response(int sockfd, int status, const char *content_type,
const char *body) {
char status_line[64];
snprintf(status_line, sizeof(status_line), "HTTP/1.1 %d OK\r\n", status);
char headers[256];
snprintf(headers, sizeof(headers),
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n",
content_type, strlen(body));
struct iovec iov[3];
iov[0].iov_base = status_line;
iov[0].iov_len = strlen(status_line);
iov[1].iov_base = headers;
iov[1].iov_len = strlen(headers);
iov[2].iov_base = (void *)body;
iov[2].iov_len = strlen(body);
writev(sockfd, iov, 3);
}四、本章小结
本章讲解了网络编程的高级特性,核心要点回顾:
非阻塞I/O:通过fcntl设置O_NONBLOCK。非阻塞模式下,read/write在无法立即完成时返回EAGAIN。通常与epoll结合使用,构建高性能异步服务器。
非阻塞connect:connect返回EINPROGRESS表示连接进行中。使用select/epoll等待连接完成,通过getsockopt(SO_ERROR)检查结果。
散列/聚集I/O:readv/writev允许一次系统调用读写多个缓冲区。减少系统调用开销,保证原子性。
带外数据:TCP的OOB机制只支持1字节,实用性差。不推荐使用,应用层应自行定义优先级。
描述符传递:通过UNIX域套接字的辅助数据(ancillary data)传递文件描述符。使用sendmsg/recvmsg和SCM_RIGHTS。
SO_PEERCRED:获取UNIX域套接字对端进程的PID/UID/GID,用于身份验证和权限控制。
五、思考与练习
思考题
非阻塞I/O + epoll的ET模式为什么必须循环读取直到EAGAIN?如果只读一次会有什么问题?
writev保证原子性是什么意思?在什么场景下原子性很重要?
描述符传递后,原进程和接收进程是否共享同一个文件偏移量?为什么?
为什么TCP的OOB机制被设计得很简单(只有1字节)?这反映了什么设计哲学?
SO_PEERCRED只能用于UNIX域套接字,不能用于TCP套接字,为什么?
编程练习
完全非阻塞HTTP服务器:实现一个基于epoll的非阻塞HTTP服务器,支持并发连接,使用writev发送响应。
描述符传递的文件服务器:实现两个进程,一个负责打开文件,另一个负责读取文件内容。通过UNIX域套接字传递文件描述符。
非阻塞connect超时管理器:实现一个库,支持多个并发的非阻塞connect,每个连接可以设置独立的超时时间。