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