9UNIX网络编程(第3版)

高级套接字编程

高级技术

原始套接字广播多播

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 设置非阻塞模式

c
#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

c
// 设置非阻塞
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多路复用结合使用,构建高性能异步服务器:

c
// 设置非阻塞
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(聚集写)

c
#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 将多个缓冲区的数据一次性写入文件描述符。

c
// 发送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(散列读)

c
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

readv 将读取的数据分散到多个缓冲区中。

c
// 读取固定格式的协议消息
// 消息格式:[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数据

c
// 发送带外数据
char oob_byte = 0xFF;
send(sockfd, &oob_byte, 1, MSG_OOB);

9.3.3 接收OOB数据

c
// 方式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 发送描述符

c
#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 接收描述符

c
// 接收描述符的辅助函数
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 使用示例

c
// 进程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 使用方法

c
#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服务器

c
#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响应

c
#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,每个连接可以设置独立的超时时间。

9/10高级套接
0%