2. 网络基础与Socket编程:TCP/IP协议栈回顾、Socket API详解、非阻塞I/O与多路复用

做低延迟交易,网络这块是绕不开的硬骨头。很多人觉得写个Socket收发数据很简单,但真到了纳秒级竞争的环境下,你会发现标准写法根本跑不动。今天我就带你从底层过一遍,哪些地方能榨出性能,哪些坑必须避开。

2.1 TCP/IP协议栈回顾——别小看这层

先快速过一下TCP/IP。你想想看,一条数据从你的交易程序发出去,到交易所的撮合引擎,中间经历了什么?

  • 应用层:你的FIX协议、OUCH协议,或者自研的二进制协议。
  • 传输层:TCP/UDP。做低延迟交易,UDP用得更多,但TCP也有它的场景(比如风控、回执确认)。
  • 网络层:IP路由。这里有个坑——路由跳数多了,延迟直接飙升。
  • 链路层:MAC地址、网卡驱动。嗯,这里往往是性能瓶颈的隐藏点。

核心观点:低延迟优化的本质,就是尽量让数据在「用户态」完成处理,少进内核,少拷贝。

我个人习惯把TCP/IP协议栈的延迟拆成三块:

  1. 协议处理延迟:TCP的拥塞控制、重传、滑动窗口。这些在交易场景下能关就关。
  2. 内核态/用户态切换:每次send/recv都是系统调用,成本很高。
  3. 数据拷贝:从网卡到内核缓冲区,再到用户缓冲区,每多一次拷贝就多几十纳秒。

我在项目中遇到过一个问题:某次策略回测表现很好,但实盘时延迟忽高忽低。查了半天,发现是TCP Nagle算法在作怪——它会把小包合并后再发,导致某些订单的发送延迟了200微秒。200微秒啊,在撮合队列里可能已经排了几千笔单子了。

避坑指南:我曾经在配置TCP_NODELAY时忘了设置,结果延迟抖动从微秒级变成了毫秒级。记住,做交易一定要禁用Nagle算法。

2.2 Socket API详解——你用的可能是假Socket

很多人写Socket代码就是教科书那套:socket()、bind()、listen()、accept()、recv()、send()。但低延迟场景下,这些API的用法完全不同。

先看一个最基础的TCP服务端示例:

// 低延迟场景下的Socket创建
int sock = socket(AF_INET, SOCK_STREAM, 0);

// 关键:设置TCP_NODELAY,禁用Nagle算法
int flag = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

// 关键:设置SO_REUSEADDR,允许快速重启
int reuse = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

// 关键:设置SO_RCVBUF和SO_SNDBUF,调大缓冲区
int rcvbuf = 1024 * 1024;  // 1MB
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;

bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 1024);

这里有几个细节,我重点说一下:

  • TCP_NODELAY:必须设。不设的话,小包会等200ms才发。
  • SO_REUSEADDR:方便你快速重启服务。交易系统经常需要热更新,这个选项能省掉等待TIME_WAIT的时间。
  • 缓冲区大小:调大接收和发送缓冲区,可以减少系统调用的次数。但别太大,1MB左右就够了,太大反而浪费内存。

注意:SO_RCVBUF设置的是内核缓冲区大小,不是用户态缓冲区。内核缓冲区越大,数据积压越多,延迟反而可能变高。我建议根据你的流量模型来调,一般512KB到2MB之间。

再说说accept()。标准写法是每来一个连接就accept一次,然后fork一个线程去处理。但在低延迟场景下,这种做法太慢了。我一般用预创建连接池的方式——提前建立好一批连接,请求来了直接复用。

2.3 非阻塞I/O与多路复用——这才是核心

阻塞I/O的问题很明显:一个线程只能处理一个连接。如果某个连接没数据,线程就卡在那了。交易系统里,你可能同时要监控几十个交易所的行情和订单通道,阻塞模型根本扛不住。

非阻塞I/O的思路很简单:调用recv()时,如果没有数据,立即返回-1,并设置errno为EAGAIN或EWOULDBLOCK。这样线程就不会被阻塞,可以继续处理其他事情。

但非阻塞I/O有个问题:你怎么知道哪个连接有数据了?总不能轮询吧?轮询太浪费CPU了。这时候就需要多路复用

Linux下主流的多路复用机制有三种:select、poll、epoll。我直接说结论:

机制 时间复杂度 最大连接数 适用场景
select O(n) 1024(默认) 小规模,不推荐
poll O(n) 无限制 中等规模,但仍有性能问题
epoll O(1) 无限制 大规模,低延迟首选

做低延迟交易,我建议直接上epoll。为什么?因为epoll是事件驱动的,它只返回那些真正有事件发生的连接,不需要遍历所有连接。select和poll每次都要把全部连接从用户态拷贝到内核态,这个开销在连接数多的时候非常可观。

来看一个epoll的核心用法:

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

struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 边缘触发
ev.data.fd = listen_sock;

epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);

// 事件循环
struct epoll_event events[1024];
while (1) {
    int nfds = epoll_wait(epfd, events, 1024, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == listen_sock) {
            // 处理新连接
            int conn_fd = accept(listen_sock, NULL, NULL);
            // 设置非阻塞
            int flags = fcntl(conn_fd, F_GETFL, 0);
            fcntl(conn_fd, F_SETFL, flags | O_NONBLOCK);
            // 添加到epoll
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_fd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
        } else {
            // 处理数据
            handle_data(events[i].data.fd);
        }
    }
}

关键点:边缘触发(ET) vs 水平触发(LT)。我强烈建议用ET模式。ET模式下,epoll只在状态变化时通知你一次,你必须把数据全部读完。LT模式会一直通知,直到你把数据读完。ET模式可以减少系统调用次数,但要求你的代码必须能处理「读不完」的情况。

我在项目中遇到过一个问题:用ET模式时,某个连接的数据量特别大,一次recv()没读完,结果后续的数据就丢了。后来我加了个while循环,确保每次触发都把数据读完:

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

个人经验:我习惯把recv的缓冲区大小设为MTU的整数倍(比如1500字节的倍数),这样可以避免TCP分片带来的额外延迟。另外,epoll_wait的超时时间我一般设为0,或者一个很小的值(比如1ms),保证事件循环的响应速度。

2.4 实战中的几个优化点

说了这么多,最后总结几个我在实战中常用的优化技巧:

  • 使用SO_BUSY_POLL:这个选项可以让网卡在收到数据时直接唤醒等待的进程,减少中断延迟。但要注意,它会占用CPU,适合CPU核数多的场景。
  • 绑定CPU核心:把网络线程绑定到特定的CPU核心上,避免上下文切换。用sched_setaffinity()可以实现。
  • 使用内存池:每次recv都malloc/free太慢了。我一般预分配一批缓冲区,用环形队列管理。
  • 避免锁竞争:多线程环境下,epoll_wait本身是线程安全的,但你的数据处理逻辑要注意加锁粒度。我建议用无锁队列。

最后提醒一句:低延迟优化是个系统工程,网络只是其中一环。别光盯着Socket调优,CPU缓存命中率、内存分配策略、甚至编译器的优化选项,都会影响最终延迟。我曾经花了两周调网络参数,结果发现瓶颈在内存分配器上——换了jemalloc后延迟直接降了30%。

好了,这一章的内容就到这。下一章我们会深入内存管理,讲讲怎么在C++里做零拷贝和内存池。到时候见。