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 分片。
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 为 EAGAIN 或 EWOULDBLOCK。这时候你不能认为出错了,而是应该继续干别的事。
我曾经犯过一个低级错误:非阻塞模式下 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 是批量返回的,一次返回的事件越多,系统调用次数越少,性能越好。
- 使用
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 的接收缓冲区大小,我建议设到 4MB 以上,防止瞬时流量把数据冲走。还有 epoll 的事件处理,一定要避免在回调里做耗时操作,比如内存分配、日志打印等。
好了,网络编程这块就聊到这。记住一句话:高频行情系统,延迟是命,吞吐是魂。Socket 选对模式,epoll 配好参数,你的数据通道就成功了一半。