2. 网络基础与Socket编程:TCP/UDP协议对比,Socket API详解,非阻塞I/O与多路复用

做量化交易系统,网络这块是绕不开的坎。行情数据从交易所出来,经过网络到达你的策略,这中间每一微秒的延迟都可能是利润或亏损。今天我们就来聊聊网络基础,重点放在Socket编程上。这部分内容,说白了就是你的系统跟外界打交道的「嘴巴」——怎么说话、说多快、能不能同时跟多人聊天,全看它。

2.1 TCP vs UDP:选谁?

先解决一个最基础的问题:用TCP还是UDP?很多新手会纠结,我直接给结论——行情解码用UDP,交易下单用TCP。为什么?

核心区别一句话:TCP保证数据一定到,但慢;UDP不保证到,但快。

咱们拆开看:

特性 TCP UDP
连接方式 面向连接(三次握手) 无连接
可靠性 可靠传输,丢包重传 不可靠,丢包不重传
有序性 保证数据包顺序 不保证顺序
延迟 较高(有确认机制) 极低(无确认)
头部开销 20字节 8字节
典型场景 交易下单、HTTP 行情广播、音视频

我个人习惯,在行情解码模块里只用UDP。原因很简单:行情数据是「快照」性质的,每秒可能来几千甚至几万笔。如果某笔丢了,下一笔马上就到,你根本不需要重传。重传反而会阻塞后续数据,造成更大的延迟。我在项目中遇到过有人用TCP收行情,结果网络抖动时,重传机制导致整个解码线程卡住,行情堆积了几百毫秒——这在量化交易里是致命的。

我的建议:如果你做的是Level-2行情解码,UDP是唯一选择。TCP只适合做交易下单,因为下单指令必须保证送达且顺序正确。

2.2 Socket API详解:从创建到收发

Socket编程,说白了就是操作系统给你的一套网络操作接口。咱们从最基础的开始,一步步搭一个UDP接收端。

2.2.1 创建Socket

// C++ 示例:创建UDP Socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}

这里注意几个参数:AF_INET表示IPv4,SOCK_DGRAM表示UDP。如果你用TCP,就换成SOCK_STREAM。嗯,就这么简单。

2.2.2 绑定地址

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);        // 行情端口
addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网卡

if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
    perror("bind failed");
    close(sock);
    exit(EXIT_FAILURE);
}

这里有个坑:htons一定要用。网络字节序是大端,而你的机器可能是小端。我曾经见过一个同事,忘了做字节序转换,结果绑定的端口跟实际差了256倍,排查了半天才发现。

2.2.3 接收数据

char buffer[65536];  // 足够大,UDP最大64KB
struct sockaddr_in sender;
socklen_t sender_len = sizeof(sender);

ssize_t recv_len = recvfrom(sock, buffer, sizeof(buffer), 0,
                            (struct sockaddr*)&sender, &sender_len);
if (recv_len > 0) {
    // 处理行情数据
    process_market_data(buffer, recv_len);
}

这里我习惯用recvfrom而不是recv,因为UDP是无连接的,你需要知道数据从哪来的。虽然行情通常是广播,但调试时能知道来源IP很有用。

注意:UDP接收缓冲区默认可能不够大。在高频行情场景下,建议调大内核缓冲区:setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size))。我一般设到4MB以上。

2.3 非阻塞I/O:别让系统等你

默认情况下,Socket是阻塞的。什么意思?你调用recvfrom时,如果没有数据,线程就卡在那了。这在单线程行情解码里是灾难——你想想看,如果行情还没到,线程就睡大觉,等数据来了才醒,这中间浪费了多少时间?

解决方案:非阻塞I/O。

// 设置非阻塞
int flags = fcntl(sock, F_GETFL, 0);
fcntl(sock, F_SETFL, flags | O_NONBLOCK);

// 现在recvfrom会立即返回
// 如果没有数据,返回-1,errno设为EAGAIN
while (true) {
    ssize_t n = recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL);
    if (n > 0) {
        process_market_data(buffer, n);
    } else if (n == -1 && errno != EAGAIN) {
        // 真正的错误
        perror("recvfrom error");
    }
    // 如果EAGAIN,继续循环,或者做其他事情
}

非阻塞的好处是:你的线程可以一直轮询,数据一到立刻处理。但坏处也很明显——如果没数据,CPU就在空转,浪费资源。怎么办?用多路复用。

2.4 多路复用:select/poll/epoll

多路复用的核心思想:一个线程同时监控多个Socket,哪个有数据就处理哪个。这在行情解码里特别有用——你可能同时收多个交易所的行情,或者同时收行情和发心跳。

2.4.1 select:老古董但够用

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);

struct timeval tv = {0, 1000};  // 1ms超时
int max_fd = max(sock1, sock2) + 1;

int ret = select(max_fd, &readfds, NULL, NULL, &tv);
if (ret > 0) {
    if (FD_ISSET(sock1, &readfds)) {
        // 处理sock1的数据
    }
    if (FD_ISSET(sock2, &readfds)) {
        // 处理sock2的数据
    }
}

select的问题:fd_set默认只有1024位,而且每次调用都要把整个集合从用户态拷贝到内核态。如果你监控的Socket超过1024,或者数据量很大,select就不够用了。

2.4.2 poll:稍微好点

struct pollfd fds[2];
fds[0].fd = sock1;
fds[0].events = POLLIN;
fds[1].fd = sock2;
fds[1].events = POLLIN;

int ret = poll(fds, 2, 1);  // 1ms超时
if (ret > 0) {
    if (fds[0].revents & POLLIN) {
        // 处理sock1
    }
    if (fds[1].revents & POLLIN) {
        // 处理sock2
    }
}

poll去掉了1024的限制,但每次调用还是要遍历所有fd。如果你有几千个连接,效率依然不高。

2.4.3 epoll:Linux下的王者

做低延迟系统,我强烈推荐epoll。它解决了select/poll的两个核心问题:

  • 事件驱动:只返回有事件的fd,不用遍历全部
  • 零拷贝:通过mmap共享内核和用户空间,减少数据拷贝
// 创建epoll实例
int epfd = epoll_create1(0);

// 添加Socket到epoll
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 边缘触发
ev.data.fd = sock1;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock1, &ev);

// 事件循环
struct epoll_event events[64];
while (true) {
    int n = epoll_wait(epfd, events, 64, -1);  // 阻塞等待
    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            int fd = events[i].data.fd;
            // 处理这个fd上的数据
            handle_data(fd);
        }
    }
}

关键点:epoll有两种触发模式——水平触发(LT)和边缘触发(ET)。做低延迟行情,我建议用边缘触发(ET)。为什么?LT模式下,只要缓冲区还有数据,epoll_wait就会一直返回,容易造成重复唤醒。ET模式下,只有新数据到达时才触发,效率更高。但ET要求你一次把数据读完,否则会丢失事件。

2.5 实战经验:一个完整的UDP行情接收循环

最后,我分享一个我在项目中实际使用的模式。这个模式结合了非阻塞I/O和epoll,延迟控制在微秒级:

// 伪代码,展示核心逻辑
class MarketDataReceiver {
public:
    void start() {
        // 1. 创建UDP Socket,设置非阻塞
        int sock = create_udp_socket(port);
        set_nonblocking(sock);
        
        // 2. 设置大缓冲区
        int rcvbuf = 4 * 1024 * 1024;  // 4MB
        setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
        
        // 3. 加入epoll,边缘触发
        int epfd = epoll_create1(0);
        struct epoll_event ev;
        ev.events = EPOLLIN | EPOLLET;
        ev.data.fd = sock;
        epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
        
        // 4. 事件循环
        struct epoll_event events[64];
        char buffer[65536];
        
        while (running_) {
            int n = epoll_wait(epfd, events, 64, 0);  // 非阻塞
            for (int i = 0; i < n; i++) {
                if (events[i].events & EPOLLIN) {
                    // 边缘触发:必须循环读取直到EAGAIN
                    while (true) {
                        ssize_t len = recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL);
                        if (len > 0) {
                            decode_and_publish(buffer, len);
                        } else if (len == -1 && errno == EAGAIN) {
                            break;  // 数据读完
                        } else {
                            // 处理错误
                            break;
                        }
                    }
                }
            }
        }
    }
};

避坑指南:我曾经在边缘触发模式下只调用了一次recvfrom,结果缓冲区里还有数据没读完,epoll却不再触发事件了。那批行情数据就这么丢了。记住:ET模式下,一定要循环读取直到返回EAGAIN。

好了,网络基础和Socket编程就聊到这。这部分内容虽然基础,但却是整个低延迟系统的地基。地基不稳,上层再优化也没用。下一节我们会深入内存管理,聊聊怎么避免那些「看不见的」延迟。


专注资料整理