打开导航 打开导航 Open menu
量化交易系统开发(C++)/ 期权策略与风控 / 行业研究分析
量化交易系统開發(C++)/ 期权策略与风控 / 行业研究分析
C++ trading systems / options strategy & risk controls / equity research
adrian@adrianxv.cn

交易系统中的 C++ 网络编程(TCP篇) 交易系统中的 C++ 网络编程(TCP篇) 交易系统中的 C++ 网络编程(TCP篇)

回顾一下这章交易系统拓扑图:在左边的行情传输部分通常使用 UDP,而右边的订单数据传输部分通常使用 TCP。本篇笔记主要关注右侧订单传输部分。

alt text

建立连接

客户端一侧

右侧负责传输订单信息的部分,使用 TCP 协议。下方是 MARKET PARTICIPANT 侧。作为 TCP 连接的客户端,它需要完成:

  1. 调用 socket() 创建一个客户端 socket fd;
  2. 配置 O_NONBLOCK、TCP_NODELAY 等;
  3. 调用 connect() 连接交易所;
  4. 连接成功后,把订单编码成协议消息;
  5. 通过 send() 发给交易所;
  6. 通过 recv() 接收订单确认、成交回报等。

alt text

ORDER GATEWAY PROTOCOL ENCODER & DECODER 不属于传输层,而是属于应用层。负责把内部订单对象转换成字节,或者把字节还原成订单回报。

服务器一侧

区分两种 fd,监听 fd 和连接 fd

  • 监听 fd 负责对新的客户端连接调用 accept()
  • 连接 fd 负责与客户端 socket 互发消息

交易所作为 TCP 连接的服务器,需要完成:

  1. 通过 socket() 创建一个 socket 并得到其 fd
  2. 通过 bind() 将上述 fd 绑定到服务器机器上一个 IP 地址和端口
  3. 监听 fd 负责通过 listen() 持续监听客户端连接
  4. 监听 fd 在收到客户端连接之后,调用 accept(),使得某个连接 fd 与客户端建立连接

实际交易程序通常会在服务器进程中创建一个 epoll 实例,用它同时管理监听 fd 和多个已建立连接的服务器端连接 fd:

  • 监听 fd 出现可读事件时,表示连接队列中可能有新的客户端连接,调用 accept()
  • 某个服务器端连接 fd 出现可读事件时,表示客户端已经发送了数据,调用 recv() 读取;如果读到 0,表示客户端已经关闭连接。
  • 某个服务器端连接 fd 出现可写事件时,表示内核发送缓冲区有空间;如果应用发送缓冲区中还有未发送数据,调用 send() 继续发送。

代码实现

  1. socket()
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  1. bind()
  • 服务器必须让客户端直到 “请连接这台机器的这个 IP 和这个端口”,因此服务器需要显式将 fd 绑定到 IP 和端口上
  • 而客户端可以不 bind(),因为服务器不需要主动寻找客户端。
  • 当 connect() 执行时,操作系统会为客户端分配临时 IP 地址和端口
  • 如果期望客户端每次连接都从同一个 IP 地址和端口发出,则可以显式 bind()
bind(fd, 本机地址和端口);
  1. listen() 实质上是把当前 TCP socket 变成监听 socket
listen(listen_fd);
  1. accept()
connection_fd = accept(listen_fd)

低延迟思想

  1. 避免阻塞:使用 non-blocking
  2. 避免一个线程对应一个连接:使用 epoll
  3. 减少小 TCP 消息的发送等待:关闭 Nagle 算法
  4. 记录数据到达时刻:SO_TIMESTAMP
  5. 减少传统网络栈路径的额外开销:kernel bypass

使用 non-blocking 避免阻塞

non-blocking 是 socket 的一种属性。设置后,读写操作不能长时间等待;在 blocking 模式下,recv() 如果没有数据的话会一直等待;non-blocking 模式下,recv() 如果当前没有数据就立即返回,告知调用者 “现在无数据”

non-blocking 通常和 epoll 一起用:

  • 如果有 epoll 但是没有 non-blocking:epoll 告诉你 fd 可读,但是 recv 可能阻塞
  • 如果有 non-blocking 但是没用 epoll:fd 不会阻塞,但是不知道何时该重试

设置 non-blocking 的方法:

int flags = fcntl(fd, F_GETFL, 0);       // 获取该 fd 原本的 flags
fcntl(fd, F_SETFL, flags | O_NONBLOCK);  // 保留原本的 flags,并额外打开 O_NONBLOCK 这一位

这里的 fcntlfile control 的意思,用于读取或修改 fd 的属性

设置 non-blocking 之后,如何调用 recv():

  1. 根据 recv() 的返回值来判断操作状态
  • n > 0 表示读到了 n 字节
  • n == 0 表示对端关闭
  • n == -1 表示错误
  1. 在 n == -1 的情况下,查看 errno 来判断具体错误
  • errno 是整数错误码,独立于 recv() 的返回值,用于记录失败细节。这是 POSIX API 的机制
  • errno 是线程局部变量,因此多个线程不会相互覆盖
  • errno == EAGAIN / EWOULDBLOCK 表示 “当前不能读,之后再试”
  • errno == ECONNRESET 表示 “对端异常重置了连接”
  • errno == EBADF 表示 “fd 无效”

使用 epoll 避免一个线程对应一个连接

通过 epoll 实现 IO multiplexing:希望一个线程可以管理监听 fd 和大量连接 fd。在没有 epoll 时,accept(listen_fd), recv(connection_fd_1), recv(connection_fd_2) 等等这些 blocking socket 只能按顺序处理;epoll 避免因某个没有事件的 blocking fd 而卡住整个事件循环。

监听 socket 上和连接 socket 上使用 epoll 的机制是不同的,但是它们可以放在同一个 epoll 实例里:

在监听 socket 上使用 epoll

  1. 将 listen_fd 注册到 epoll
  2. epoll_wait() 报告 listen_fd 可读
  3. accept(listen_fd)
  4. 得到 connection_fd

在连接 socket 上使用 epoll:

  1. 将 connection_fd 注册到 epoll
  2. epoll_wait()
  3. connection_fd 可读
  4. recv(connection_fd, …)

关闭 Nagle 算法以减少小 TCP 消息发送等待

假设应用频繁发送很小的数据,如果每次都立即发送,就会产生很多小 TCP 包,每个包都有 header,导致网络利用率较低。Nagle 算法会尝试暂时缓存小块数据,等待已有数据积累到较大的数据块再发送出去,以此提高吞吐效率。

但是在低延迟交易系统当中我们要禁止 Nagle 算法。原因是在交易场景下,行情、订单、请求等消息可能很小,但是必须尽快发送。

关闭 Nagle 算法的方法:

setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, ...);

此处 IPPROTO_TCP 表示此处设置的是 TCP 协议层的选项 TCP_NODELAY = 1 表示关闭 Nagle 算法

使用 SO_TIMESTAMP 记录数据到达时刻

让内核在收到网络数据并把它交给某个 socket 时,附带一个 “接收时间”

使得程序可以严格按照数据到达时间来处理数据。因为 epoll 只负责发现 fd 可读,但是 epoll 的唤醒顺序未必等于数据到达顺序。

启用方法:

int enabled = 1;
setsockopt(
    fd,
    SOL_SOCKET,
    SO_TIMESTAMP,
    &enabled,
    sizeof(enabled)
);

这里的 SO_TIMESTAMP 标志开启了接收时间戳

使用 recvmsg() 来接收数据: 相比于普通 recv(),recvmsg() 可以接收启用 SO_TIMESTAMP 后内核额外返回的控制信息

recvmsg(fd, &message, 0);

这里的 message 包含两类数据:

  • 普通数据(应用协议 payload)
  • 控制信息(时间戳等元数据)

使用 kernel bypass 减少传统网络栈路径的额外开销

让程序尽量绕过操作系统内核的传统网络数据路径,直接或通过专用用户专用用户态库与网卡交换数据,从而减少系统调用、上下文切换和内存复制

普通 socket send() 的路径:

  1. 应用程序
  2. send()
  3. 系统调用
  4. 内核 socket 缓冲区
  5. 内核 TCP/IP 协议栈
  6. 网卡驱动
  7. 网卡(NIC)

层数较多,保证安全,但是每一层都带来成本:

  1. 系统调用成本
  2. 上下文切换成本
  3. 内核缓冲区与应用缓冲区之间的数据复制
  4. 通用协议栈的检查、排队和调度
  5. 中断处理带来延迟抖动
  6. 多层锁和共享队列竞争

Kernel Bypass 实现了简化:

  1. 应用程序
  2. 用户态网络库
  3. 共享内存/环形队列
  4. 网卡(NIC)

这部分实现较为复杂,有以下支持 kernel bypass 的 NIC 和 API,此处不深入:

  1. Solarflare OpenOnload
  2. ef_vi
  3. TCPDirect
  4. Exablaze 的专用网卡方案