3. 网络编程基础:Socket API、TCP/UDP协议、非阻塞I/O与epoll模型

做高频行情解析,说白了就是跟网络数据包打交道。你策略再牛,模型再准,数据从网线到内存这关过不去,一切都是白搭。我见过太多团队,算法研究得天花乱坠,结果一到实盘就卡在数据接收上,延迟高得离谱。

这一章,咱们就把网络编程的底裤扒干净。从最基础的 Socket API 讲起,到 TCP/UDP 怎么选,再到非阻塞 I/O 和 epoll 这个高频场景下的王者模型。嗯,都是实战里摸爬滚打出来的经验。

3.1 Socket API:你与网卡之间的对话窗口

Socket 是什么?说白了就是一个文件描述符。你往里面写数据,网卡就发出去;网卡收到数据,你就能读出来。Linux 下一切皆文件,网络也不例外。

一个典型的 TCP 服务端流程,我习惯这么写:

// 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket");
    exit(1);
}

// 设置地址复用,避免 TIME_WAIT 问题
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

// 绑定地址
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

// 监听
listen(sockfd, 1024);

// 接受连接
struct sockaddr_in client_addr;
socklen_t len = sizeof(client_addr);
int clientfd = accept(sockfd, (struct sockaddr*)&client_addr, &len);

这里有个坑,我踩过好几次。就是那个 SO_REUSEADDR 选项。如果不设置,服务端崩溃重启后,端口还处于 TIME_WAIT 状态,bind 会失败。高频场景下,服务重启是家常便饭,这个选项必须加上。

注意: 生产环境中,listen 的 backlog 参数不要设太小。我建议至少 1024,否则高并发下内核会丢连接。

3.2 TCP vs UDP:高频场景下怎么选?

这个问题,每次面试都有人问。我的回答很简单:看你的数据能不能丢。

特性 TCP UDP
可靠性 保证不丢包、按序到达 不保证,可能丢包、乱序
延迟 较高(三次握手、重传机制) 极低(无连接、无确认)
适用场景 订单、交易确认 行情快照、Level2 数据

我个人习惯是:行情用 UDP,交易用 TCP。为什么?行情数据量大、频率高,偶尔丢一帧问题不大,下一帧马上补回来。但交易指令丢了,那可是真金白银的损失。

UDP 编程比 TCP 简单得多,不需要 listen 和 accept:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(9999);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

char buf[1500];
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
int n = recvfrom(sockfd, buf, sizeof(buf), 0, 
                 (struct sockaddr*)&from, &fromlen);

你看,UDP 收数据就一个 recvfrom 搞定。但要注意,UDP 包最大 65535 字节,以太网 MTU 是 1500,所以实际应用层包最好控制在 1472 字节以内,避免 IP 分片。

实战技巧: 我曾在项目中用 UDP 接收行情,发现偶尔有乱序。解决方案很简单——每个数据包加一个序列号,接收端自己排序。开销很小,但效果立竿见影。

3.3 非阻塞 I/O:别再让程序傻等了

默认情况下,socket 是阻塞的。你调用 recv,如果没有数据,线程就挂在那等。在高频场景下,这是不可接受的。你想想看,一个线程卡在 recv 上,其他连接的数据谁来处理?

解决办法就是非阻塞 I/O。设置方法很简单:

// 方法一:创建时指定
int sockfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);

// 方法二:运行时设置
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

设置成非阻塞后,recv 如果没有数据,会立即返回 -1,并设置 errno 为 EAGAINEWOULDBLOCK。这时候你不能认为出错了,而是应该继续干别的事。

我曾经犯过一个低级错误:非阻塞模式下 recv 返回 -1,我直接打印错误日志并关闭连接。结果线上疯狂断连,排查了半天才发现是 EAGAIN 没处理。嗯,从那以后我写了个工具函数:

int safe_recv(int fd, char* buf, size_t len) {
    int n = recv(fd, buf, len, 0);
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            return 0;  // 没数据,正常
        }
        return -1;  // 真出错了
    }
    return n;
}

3.4 epoll:高并发下的终极武器

非阻塞 I/O 解决了线程挂起的问题,但新的问题来了:你怎么知道哪个 socket 有数据可读?总不能轮询所有 socket 吧?那 CPU 就全浪费在系统调用上了。

这就是 epoll 登场的时候了。它是 Linux 下最高效的 I/O 多路复用机制。说白了,就是内核帮你盯着所有 socket,哪个有数据了,主动通知你。

epoll 的核心 API 就三个:

// 1. 创建 epoll 实例
int epfd = epoll_create1(0);

// 2. 注册事件
struct epoll_event ev;
ev.events = EPOLLIN;  // 关注可读事件
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);

// 3. 等待事件
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
    if (events[i].events & EPOLLIN) {
        // 处理可读事件
        handle_read(events[i].data.fd);
    }
}

这里有个关键点:epoll_wait 的第三个参数是最大事件数。我建议设大一点,比如 1024。为什么?因为 epoll 是批量返回的,一次返回的事件越多,系统调用次数越少,性能越好。

高频场景下的 epoll 配置:
  • 使用 EPOLLET(边缘触发)模式,减少事件通知次数
  • 配合非阻塞 I/O,避免漏读数据
  • 每个线程一个 epoll 实例,避免锁竞争
  • 使用 epoll_create1(EPOLL_CLOEXEC) 防止 fork 后泄漏

边缘触发(ET)和水平触发(LT)的区别,我简单解释下。LT 是只要有数据没读完,就会一直通知你。ET 是只有状态变化时才通知一次。ET 效率更高,但你必须一次性把数据读完,否则就漏了。

我个人的经验是:ET + 非阻塞 + while 循环读,这是标准配置:

void handle_read(int fd) {
    char buf[4096];
    while (1) {
        int n = recv(fd, buf, sizeof(buf), 0);
        if (n > 0) {
            // 处理数据
            process_data(buf, n);
        } else if (n == 0) {
            // 连接关闭
            close(fd);
            break;
        } else {
            if (errno == EAGAIN) {
                break;  // 数据读完了
            }
            // 真出错了
            close(fd);
            break;
        }
    }
}

这个 while 循环很关键。ET 模式下,内核只通知一次,你必须把 socket 缓冲区里的数据全部读出来。如果一次 recv 没读完,剩下的数据就永远没人处理了。

性能陷阱: 不要在每个事件处理函数里都调用 epoll_ctl 修改事件。这个系统调用开销不小。我建议在连接建立时就把所有关注的事件注册好,运行时只通过 epoll_wait 获取事件。

3.5 实战:一个简单的高频行情接收器

把上面这些知识点串起来,就是一个完整的高频行情接收器。核心逻辑用 SVG 画出来,大家看得更清楚:

高频行情接收器核心流程 UDP Socket 非阻塞模式 epoll 事件循环 ET 模式 数据解析 协议解码 关键配置: • SO_REUSEADDR:端口复用,快速重启 • SO_RCVBUF:接收缓冲区调大,减少丢包 • EPOLLET:边缘触发,减少系统调用 • O_NONBLOCK:非阻塞,避免线程挂起 ⚡ 性能参考(单线程) • 处理 1000 个 UDP 连接,CPU 占用 < 10% • 单包延迟 < 5 微秒(从网卡到应用层)

这个架构看起来简单,但每个环节都有优化空间。比如 UDP 的接收缓冲区大小,我建议设到 4MB 以上,防止瞬时流量把数据冲走。还有 epoll 的事件处理,一定要避免在回调里做耗时操作,比如内存分配、日志打印等。

我的习惯: 在 epoll 事件回调里只做一件事——把数据从内核缓冲区搬到用户态环形缓冲区。真正的解析和处理,交给工作线程去做。这样事件循环永远不会被阻塞。

好了,网络编程这块就聊到这。记住一句话:高频行情系统,延迟是命,吞吐是魂。Socket 选对模式,epoll 配好参数,你的数据通道就成功了一半。