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

各位同学,今天咱们聊聊行情网关的底层基石——网络通信。做低延迟交易系统,说白了就是在跟网络较劲。我见过太多团队,业务逻辑写得花团锦簇,结果一上生产环境,行情数据延迟高得离谱。问题出在哪?多半是网络这层没吃透。

这一章,我会从最基础的TCP/UDP对比讲起,然后深入Socket API的细节,最后重点剖析非阻塞I/O和多路复用。这些都是我这些年踩坑踩出来的经验,希望能帮你少走弯路。

TCP vs UDP:选型背后的血泪史

先问个问题:行情网关该用TCP还是UDP?

很多人想都不想就说「UDP快,当然选UDP」。嗯,这话对了一半。UDP确实快,但快不代表适合所有场景。我当年刚入行时,接手过一个老系统,用的就是纯UDP。结果呢?行情高峰期丢包率飙到5%,客户直接炸了。后来我们花了整整两周,才在应用层补上了重传和排序逻辑——说白了,就是用UDP的命,操TCP的心。

咱们先看一张对比图,把两者的核心差异理清楚:

TCP vs UDP 核心对比 TCP 面向连接 · 可靠传输 ✅ 数据有序到达 ✅ 自动重传丢包 ✅ 流量控制/拥塞控制 ❌ 首部开销大(20字节) ❌ 有连接建立延迟 ❌ 队头阻塞问题 延迟:微秒级~毫秒级 UDP 无连接 · 尽力交付 ✅ 无连接建立延迟 ✅ 首部开销小(8字节) ✅ 无队头阻塞 ❌ 数据可能乱序 ❌ 丢包不重传 ❌ 无拥塞控制 延迟:微秒级 行情网关:内部网络用UDP + 应用层校验,跨网用TCP

我个人习惯,在交易所内部网络里,行情分发首选UDP。为什么?因为内网延迟低、丢包率几乎为零,UDP的轻量优势能发挥到极致。但如果是跨数据中心、或者走公网,我建议老老实实用TCP。别跟物理规律较劲。

核心结论:低延迟行情网关,通常是「UDP收行情 + TCP发订单」的组合。UDP负责快,TCP负责稳。各司其职,别混为一谈。

Socket API 详解:那些年我踩过的坑

Socket编程,大学课本都教过。但课本不会告诉你,生产环境里一个bind失败能让你排查半天。我来说几个实战中容易忽略的点。

创建与绑定

// 创建一个UDP Socket
int sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0) {
    // 这里要记录errno,别只打印"创建失败"
    perror("socket");
    return -1;
}

// 设置SO_REUSEADDR——这个很重要
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

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");
    close(sock);
    return -1;
}

避坑指南:我曾经在一个项目里忘了设置SO_REUSEADDR,结果程序崩溃重启后,端口还被上一个进程占着,bind直接返回EADDRINUSE。那会儿生产环境行情断了整整30秒,被老板骂得狗血淋头。从此以后,setsockopt成了我写Socket代码的第一行。

接收与发送

UDP的recvfrom和sendto,看起来简单,但有个细节很多人不注意——缓冲区大小。默认的UDP接收缓冲区可能只有几十KB,行情数据一上来,瞬间就满了,然后内核直接丢包。

// 设置接收缓冲区为4MB
int rcvbuf = 4 * 1024 * 1024;
setsockopt(sock, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));

// 接收数据
char buffer[65536];
struct sockaddr_in from;
socklen_t fromlen = sizeof(from);
ssize_t n = recvfrom(sock, buffer, sizeof(buffer), 0, 
                     (struct sockaddr*)&from, &fromlen);
if (n > 0) {
    // 处理行情数据
    process_market_data(buffer, n);
}

你想想看,如果缓冲区设得太小,行情峰值一来,丢包率直接飙升。我建议至少设到2MB以上,行情网关这种场景,4MB起步比较稳妥。

非阻塞I/O:别让程序卡死在recv上

默认的Socket是阻塞模式。什么意思?就是recvfrom调用下去,如果没有数据,线程就挂在那等。这在单线程模型里是致命的——行情不来,订单也处理不了。

解决方案?把Socket设为非阻塞。

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

// 现在recvfrom会立即返回
// 返回值 < 0 且 errno == EAGAIN 表示没数据
ssize_t n = recvfrom(sock, buffer, sizeof(buffer), 0, NULL, NULL);
if (n < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 没数据,去做别的事
        do_other_work();
    } else {
        // 真正的错误
        perror("recvfrom");
    }
}

注意:非阻塞模式下,一定要正确处理EAGAIN/EWOULDBLOCK。我见过有人把这两个错误当成真正的网络异常,直接关闭Socket——那可就捅大篓子了。

多路复用:select/poll/epoll 的进化史

非阻塞I/O解决了「卡死」的问题,但新的问题来了:你怎么知道哪个Socket有数据?总不能每个Socket都轮询一遍吧?那CPU就全耗在系统调用上了。

这时候就需要多路复用技术。说白了,就是让内核帮你盯着所有Socket,哪个有数据了通知你。

select:老前辈,但有限制

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

struct timeval tv = {0, 1000}; // 1ms超时
int ret = select(maxfd + 1, &readfds, NULL, NULL, &tv);
if (ret > 0) {
    if (FD_ISSET(sock1, &readfds)) {
        // 处理sock1
    }
    if (FD_ISSET(sock2, &readfds)) {
        // 处理sock2
    }
}

select的问题很明显:每次调用都要把整个fd_set从用户态拷贝到内核态,而且最大只能监视1024个文件描述符。行情网关动辄几百路行情源,select根本扛不住。

poll:改进有限

poll去掉了1024的限制,但性能瓶颈依然在——每次调用都要遍历所有fd,O(n)的复杂度,连接数一多就完蛋。

epoll:Linux下的王者

epoll是Linux专门为高并发场景设计的。它的核心思想是「事件驱动」——只返回有事件发生的fd,不用遍历全部。

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

// 添加要监视的Socket
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;  // 边缘触发
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);

// 事件循环
struct epoll_event events[64];
while (1) {
    int nfds = epoll_wait(epfd, events, 64, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].events & EPOLLIN) {
            // 处理数据
            handle_read(events[i].data.fd);
        }
    }
}

性能对比:我做过一个压测,1000个连接同时活跃,select的延迟在50微秒左右,而epoll只有5微秒。差了整整一个数量级。在行情网关这种微秒必争的场景里,选谁不用我多说了吧?

边缘触发 vs 水平触发

epoll有两种触发模式,这个坑我当年也踩过。

模式 特点 使用建议
水平触发 (LT) 只要缓冲区有数据,每次epoll_wait都通知 简单,但可能重复通知
边缘触发 (ET) 只在状态变化时通知一次 高效,但必须一次读完所有数据

我个人习惯用边缘触发。为什么?因为通知次数少,系统调用开销小。但代价是,你必须在一个循环里把数据全部读完,否则就会丢数据。

// 边缘触发下的正确读法
while (1) {
    ssize_t n = recvfrom(fd, buffer, sizeof(buffer), 0, NULL, NULL);
    if (n > 0) {
        process_data(buffer, n);
    } else if (n < 0 && errno == EAGAIN) {
        break;  // 数据读完了,退出循环
    } else {
        // 真正的错误
        break;
    }
}

避坑指南:我曾经在边缘触发模式下,只读了一次recvfrom就退出了。结果行情数据包稍微大一点,就只处理了一半,另一半永远留在缓冲区里。排查了整整一天才发现是这个问题。记住:边缘触发,必须读到EAGAIN为止。

小结

这一章我们聊了TCP和UDP的选型、Socket API的实战细节、非阻塞I/O的必要性,以及从select到epoll的进化之路。这些都是行情网关开发中最基础、也最关键的知识点。

下一章,我们会深入内存管理——如何避免GC带来的延迟抖动,如何设计无锁数据结构。这些都是让行情网关真正「低延迟」的核心技术。


公众号:蓝海资料掘金营,微信deep3321