← 返回模块
3.4.4.4beta 可读 · 未来付费内容校验中内容版本 2026-05-27

套接字、Asio 与 FIX / ITCH 协议

3.4.4 · 并发与网络 · 编程

国内某头部 quant 的 510300.SH 做市组新入职 C++ 工程师,被安排与一位资深做一周入职配对。第一天:读 200 行 FIX 会话层代码。第二天:读 300 行 ITCH 5.0 解析器。第三天:把一笔 NEWORDERSINGLE 从策略层往下追,穿过桌内会话处理器、跨 TCP 套接字送到跨境清算柜台,再以 EXECUTIONREPORT 形式回来。第四天:在 Wireshark 里读 tcpdump -i any -w trace.pcap port 4001 的输出,并解释每一个字节。第五天:写一个开了 TCP_NODELAY 的微型 TCP echo 服务,证明它在同一主机上 64 字节消息往返 < 20 μs,并写一个 60 行的 FIX 4.4 解析器:能校验 checksum、能漂亮打印 NEWORDERSINGLE。到周五,新人就学会了在这个桌子里把「策略程序员」与「系统程序员」区分开的那一面操作面。本课就是那一周的压缩版。L4 拥有「线」——BSD 套接字、FIX 4.4 会话报文、ITCH 5.0 二进制行情记录、tcpdump / tshark 这类日常工具、以及一段 Asio 简介。L5 再讲:本课建立的下限之上每一微秒,是怎么被内核旁路一层层剥下来的。

(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)

BSD 套接字:操作子集

BSD 套接字 API 在 4.2BSD(1983 年)就标准化了,至今在现代 Linux、POSIX、Windows(带 WSA* 前缀)都基本不变。quant 开发者需要的操作子集很小。socket(AF_INET, SOCK_STREAM, 0) 创建 TCP 套接字;SOCK_DGRAM 是 UDP 变体。bind(fd, sockaddr*, len) 把套接字绑到本地地址与端口。listen(fd, backlog) 把 TCP 套接字标记为服务端;backlog 是内核为待处理连接准备的队列容量。accept(fd, ...) 阻塞直到有客户连接,并返回一个针对该连接的新套接字。客户端:socket,然后 connect(fd, sockaddr*, len) 到服务端地址,然后 send / recv。UDP 变体:没有 accept / connect(其实允许 connect,会把目的地钉住);每次调用 sendto / recvfrom 时给目的地址。

生产行情链路跑在 UDP 组播上——交易所发一个包、所有订阅方都收得到。通过 setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &ip_mreq, sizeof(ip_mreq)) 加入一个组,其中 ip_mreq 给出组播组与本地接口;内核负责与交换机做 IGMP 加入。

quant 开发路径上有三个套接字选项要紧。SO_REUSEADDR(Linux 上还有 SO_REUSEPORT)让服务端在前一次绑定的 TIME_WAIT 状态熬完之前就能重启——非常关键,否则崩溃的服务在两分钟内无法回来。SO_RCVBUF / SO_SNDBUF 设置内核缓冲大小;高吞吐组播行情要抬到 16-64 MiB,因为 10 ms 的缓冲量等于几兆字节数据。TCP_NODELAY(选项层 IPPROTO_TCP)关掉 Nagle 算法——内核的小写合并会把小于 MSS 的发送批起来,凭空多出几十毫秒的延迟。每一条低延迟 TCP 报盘套接字都会在 accept 之后立刻给收到的套接字置 TCP_NODELAY

#include <arpa/inet.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/socket.h>
#include <unistd.h>

int main() {
    const int port = 9999;
    int srv = ::socket(AF_INET, SOCK_STREAM, 0);
    if (srv < 0) { std::perror("socket"); return 1; }

    int one = 1;
    ::setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));   // server may restart without TIME_WAIT block

    sockaddr_in addr{};
    addr.sin_family      = AF_INET;
    addr.sin_port        = htons(port);
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    if (::bind(srv, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) { std::perror("bind"); return 1; }
    if (::listen(srv, 64) < 0) { std::perror("listen"); return 1; }
    std::printf("echo server listening on 127.0.0.1:%d\n", port);

    for (;;) {
        sockaddr_in cli{};
        socklen_t cli_len = sizeof(cli);
        int c = ::accept(srv, reinterpret_cast<sockaddr*>(&cli), &cli_len);
        if (c < 0) { std::perror("accept"); continue; }

        ::setsockopt(c, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));   // disable Nagle for low-latency echo

        char buf[4096];
        for (;;) {
            ssize_t n = ::recv(c, buf, sizeof(buf), 0);
            if (n <= 0) break;
            ::send(c, buf, n, 0);
        }
        ::close(c);
    }
    ::close(srv);
    return 0;
}

htons / htonl 把主机字节序转为网络字节序(套接字一律大端)。INADDR_ANY 绑到所有本地接口。listen 队列深度 64 是常用起点。测试:nc 127.0.0.1 9999,敲一行,看见回显。

对于高连接数服务(成千上万客户),内核原语是 epollepoll_create1(0) 返回 epoll 实例;epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) 注册套接字;epoll_wait(efd, events, max, timeout_ms) 阻塞到至少一个已注册套接字可读 / 可写。水平触发(EPOLLIN)与边沿触发(EPOLLIN | EPOLLET)的差别:水平触发是默认且更安全(只要套接字仍可读就一直通知,部分读没问题);边沿触发更快(每次状态变化只通知一次),但要求调用方每次都把套接字读到 EAGAIN,否则会卡死在永远等不到的下一个通知上。

Asio 简介

Boost.Asio(以及独立的非 Boost 版本,见 asio.cppreference.com)把底层套接字 / epoll 装在一套回调驱动的异步 IO 框架里。中心对象是 boost::asio::io_context——事件循环。boost::asio::ip::tcp::socket 是 TCP 套接字类型;你对它 async_read / async_write,传入完成处理器,等操作完成时回调。两种生产模式:「一个线程一个 io_context」(简单可预测)与「一个 io_context + N 个工作线程都跑 io_context.run()」(可扩展到多核)。C++20 协程让 Asio 的异步写法短得多——co_await socket.async_read_some(buffer, use_awaitable)——那种写法将在 3.4.5 成为自然选择。本课 Asio 只占一段;BSD 套接字 API 足以支撑后面要做的所有事情。

FIX 4.4:跨境报盘的通用语

FIX(Financial Information eXchange)是跨境报盘与回报的主流文本会话层协议。4.4 版是主力:全球跨资产里走跨境券商、走跨境清算的报文绝大多数都跑在 FIX 4.4 上(或它的二进制堂兄 FIX/FAST,或 CME 那种基于 SBE 的 iLink 3——L4 收尾会指一下)。OUCH 是股票侧典型的二进制报盘协议替代,与 ITCH 一起在同一段收尾里被点名。

FIX 报文是用 SOH 字符(\x01)分隔的 tag=value 对。每条都以 8=FIX.4.4 开头、以 10=<checksum> 结尾。必备 header 字段:9=<body_length>35=<msgtype>34=<seqnum>49=<sender>56=<target>52=<sendingtime>。常见报文类型:LOGON(35=A)带 HeartBtInt (108)、HEARTBEAT(35=0)、NEWORDERSINGLE(35=D)带 ClOrdID (11) / Symbol (55) / Side (54: 1=Buy 2=Sell) / OrdType (40: 1=Market 2=Limit) / Price (44) / OrderQty (38)、EXECUTIONREPORT(35=8)带 OrderID (37) / ExecID (17) / ExecType (150) / OrdStatus (39) / LeavesQty (151) / CumQty (14) / AvgPx (6)。Checksum:sum(bytes) mod 256,渲染成三位零填充的 ASCII 数字。

60 行 C++17 解析器足以覆盖入职这一周看到的一切:

#include <cstdio>
#include <cstdlib>
#include <string>
#include <string_view>
#include <unordered_map>

constexpr char SOH = '\x01';

// Returns the parsed field map, or empty on parse / checksum error.
std::unordered_map<int, std::string_view>
parse_fix(std::string_view msg) {
    std::unordered_map<int, std::string_view> fields;

    // Walk the buffer, splitting on SOH into tag=value pairs.
    std::size_t i = 0;
    while (i < msg.size()) {
        std::size_t soh = msg.find(SOH, i);
        if (soh == std::string_view::npos) break;
        std::string_view kv = msg.substr(i, soh - i);
        std::size_t eq = kv.find('=');
        if (eq == std::string_view::npos) return {};
        int tag = std::atoi(std::string(kv.substr(0, eq)).c_str());
        std::string_view val = kv.substr(eq + 1);
        fields.emplace(tag, val);
        i = soh + 1;
    }

    // Validate checksum: sum(bytes) mod 256, over message up to (but not including) the "10=" field.
    std::size_t cksum_pos = msg.rfind("\x01""10=");
    if (cksum_pos == std::string_view::npos) return {};
    unsigned cksum = 0;
    for (std::size_t k = 0; k <= cksum_pos; ++k) cksum += static_cast<unsigned char>(msg[k]);
    cksum %= 256;
    char expect[4];
    std::snprintf(expect, sizeof(expect), "%03u", cksum);
    auto it = fields.find(10);
    if (it == fields.end() || it->second != expect) return {};
    return fields;
}

void print_new_order(const std::unordered_map<int, std::string_view>& f) {
    auto get = [&](int t) -> std::string_view {
        auto it = f.find(t);
        return it == f.end() ? std::string_view{} : it->second;
    };
    std::printf("NEWORDERSINGLE ClOrdID=%.*s Symbol=%.*s Side=%.*s OrdType=%.*s Price=%.*s OrderQty=%.*s\n",
                (int)get(11).size(), get(11).data(),
                (int)get(55).size(), get(55).data(),
                (int)get(54).size(), get(54).data(),
                (int)get(40).size(), get(40).data(),
                (int)get(44).size(), get(44).data(),
                (int)get(38).size(), get(38).data());
}

解析器全程用 std::string_view,永不复制消息缓冲——解析出的 fields map 持有的是对调用方持有的缓冲的视图。Checksum 校验步把比特翻转的消息拒掉。print_new_order 漂亮打印那六个承重 NEWORDERSINGLE 字段。在代码里构造一条真消息来练手:

#include <sstream>

// Build a FIX 4.4 NEWORDERSINGLE for SPY / 510300.SH. Real production code
// uses pre-allocated buffers; this is the readable form.
std::string build_new_order_single() {
    const std::string body =
        std::string("35=D")    + SOH +
        "34=2"                  + SOH +
        "49=" + "DESK_CN_01" + SOH +
        "56=" + "BROKER_CN" + SOH +
        "52=20260524-09:30:00.000" + SOH +
        "11=ORDER-001-2026"     + SOH +
        "55=" + "510300" + SOH +
        "54=1"                  + SOH +     // Buy
        "40=2"                  + SOH +     // Limit
        "44=" + "4.30"  + SOH +
        "38=" + "10000"    + SOH;

    char body_len[16];
    std::snprintf(body_len, sizeof(body_len), "%zu", body.size());
    std::string header = std::string("8=FIX.4.4") + SOH + "9=" + body_len + SOH;

    std::string msg = header + body;
    unsigned cksum = 0;
    for (char c : msg) cksum += static_cast<unsigned char>(c);
    cksum %= 256;
    char cks[8];
    std::snprintf(cks, sizeof(cks), "10=%03u\x01", cksum);
    msg += cks;
    return msg;
}

int main() {
    std::string m = build_new_order_single();
    auto fields = parse_fix(m);
    if (fields.empty()) { std::fprintf(stderr, "parse failed\n"); return 1; }
    print_new_order(fields);
    return 0;
}

跑一遍,看到那行被打印出来。把 body 中任意一个字节改坏,再跑,看到 parse_fix 返回空 map(checksum 失败)。这是能跑通的最小端到端 FIX 练习,正好把会话层完整性演一遍。CFFEX / SSE / SZSE 内盘报盘走的是 CTP 与 STEP 协议(前者由上海期货信息技术 SFIT 维护,后者是国内交易所的私有协议);本课教 FIX 是因为它是跨境与国际对接的通用语,也是公开可学的协议范本——CTP / STEP 的会话层架构是一样的(TCP、序号、会话层),只是 wire 形式不同。

ITCH 5.0:二进制行情路径

ITCH 是 TotalView 背后的二进制组播行情协议;spec 公开于 nasdaqtrader.com。每条报文以 12 字节(确切说是 stock_locate / tracking_number 之后的 11 字节)header 开头:1 字节 message type、2 字节 stock_locate、2 字节 tracking_number、6 字节时间戳(自午夜起的纳秒数)。quant 开发者会接触的报文类型:A Add Order(order_ref 8 字节、buy_sell 1 字节、shares 4 字节、stock 8 字节空格补齐 ASCII、price 4 字节 price-4 格式)、E Order Executed(order_ref、executed_shares、match_number)、X Order Cancel(order_ref、cancelled_shares)、D Order Delete(order_ref)。所有多字节字段在线上一律大端。

#include <cstdint>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

// Read big-endian 16/32/64-bit values from a byte pointer.
inline std::uint16_t be16(const std::uint8_t* p) { std::uint16_t v; std::memcpy(&v, p, 2); return __builtin_bswap16(v); }
inline std::uint32_t be32(const std::uint8_t* p) { std::uint32_t v; std::memcpy(&v, p, 4); return __builtin_bswap32(v); }
inline std::uint64_t be48(const std::uint8_t* p) {
    // ITCH timestamp is 6 bytes big-endian; promote to 8 with zero high bytes.
    std::uint8_t buf[8] = {0, 0, p[0], p[1], p[2], p[3], p[4], p[5]};
    std::uint64_t v; std::memcpy(&v, buf, 8); return __builtin_bswap64(v);
}
inline std::uint64_t be64(const std::uint8_t* p) { std::uint64_t v; std::memcpy(&v, p, 8); return __builtin_bswap64(v); }

struct AddOrder {
    std::uint64_t timestamp_ns;
    std::uint64_t order_ref;
    char          buy_sell;       // 'B' or 'S'
    std::uint32_t shares;
    char          stock[8];       // ASCII, space-padded
    std::uint32_t price_4;        // price * 1e4
};

// Parse one ITCH 'A' Add Order body (header already consumed). Returns body length consumed.
std::size_t parse_add_order(const std::uint8_t* body, std::uint64_t ts_ns, AddOrder& out) {
    out.timestamp_ns = ts_ns;
    out.order_ref    = be64(body + 0);
    out.buy_sell     = static_cast<char>(body[8]);
    out.shares       = be32(body + 9);
    std::memcpy(out.stock, body + 13, 8);
    out.price_4      = be32(body + 21);
    return 25;   // 'A' body is 25 bytes per ITCH 5.0 spec
}

// Walk an mmap-ed ITCH file, count and print the first kPrint 'A' Add Orders.
void walk_itch(const std::uint8_t* base, std::size_t size, int kPrint = 10) {
    int printed = 0;
    std::size_t i = 0;
    while (i + 11 < size) {
        const std::uint8_t mtype = base[i];
        // ITCH 5.0 header: 1 byte type, 2 byte stock_locate, 2 byte tracking_number, 6 byte timestamp.
        std::uint64_t ts_ns = be48(base + i + 5);
        if (mtype == 'A') {
            AddOrder o;
            std::size_t body_len = parse_add_order(base + i + 11, ts_ns, o);
            if (printed < kPrint) {
                std::printf("A ts=%llu ref=%llu %c shares=%u stock=%.8s price=%.4f\n",
                            (unsigned long long)o.timestamp_ns, (unsigned long long)o.order_ref,
                            o.buy_sell, o.shares, o.stock, o.price_4 * 1e-4);
                ++printed;
            }
            i += 11 + body_len;
        } else {
            // Skip unknown / unhandled message types using a small per-type length table (omitted for brevity).
            break;
        }
    }
}

两点运维注解。__builtin_bswap*(GCC / Clang)与 _byteswap_*(MSVC)是按编译器走的字节交换内建;POSIX 的 htons / htonl 只覆盖 16 与 32 位,htonll 不标准,于是生产形态直接用 __builtin_bswap64std::memcpy 模式可避开直接 *reinterpret_cast<const uint64_t*>(p) 带来的严格别名风险。mmap(片段里没贴但 walk_itch 的入参意味着)一次打开 ITCH 文件并给一个指针;内核读到才按需调页,这是处理比 RAM 大的文件的正确模式。SSE / SZSE 的 Level-2 行情协议在思想上类似,但 spec 仅向订阅方授权,所以本课用公开可学的 ITCH 来教这套技法。

tcpdumptshark 做线上调试

当 FIX 会话拒绝登录、或组播行情读不到任何包时,问题永远是「线上实际跑的是什么?」标准工具是 tcpdump;GUI 是 Wireshark;Wireshark 的 CLI 等价物是 tshark

# Capture every byte of port 9999 traffic to a pcap file.
sudo tcpdump -i lo -w /tmp/fix.pcap -s 0 port 9999

# In another shell: run the FIX echo / parse program against the loopback.
# Then stop the tcpdump (Ctrl-C) and inspect.
tshark -r /tmp/fix.pcap -x | head -50    # raw hex+ASCII dump
tshark -r /tmp/fix.pcap -V                # verbose; Wireshark's FIX dissector decodes tag=value pairs

# Capture multicast market data on a real NIC (replace 239.0.0.100 with your group).
sudo tcpdump -i eth0 -w /tmp/md.pcap -s 0 \( udp and host 239.0.0.100 \)

-s 0 让 tcpdump 抓完整包(默认只截前 96 字节)。Wireshark 内置 FIX dissector 能直接解 tag=value;ITCH 没有内置 dissector,但 -x 出来的 hex+ASCII 你对着 spec 读就行了。这就是每位 quant 入职第五天的练习。

国内 CTP 与 STEP 协议的对照

SFIT(ShanghaiFuturesInfoTechnology)发布的 CTP API 是国内 Futures 报盘事实标准;FemasAPI、KingstarAPI、JsdSdk 是几家友商的同类实现。STEP(ShanghaiTradingExchangeProtocol)是 SSE 与 SZSE 的私有协议,运行在 TCP 上,session 层与 FIX 高度同构但 wire 形式是二进制 TLV。常见 ZJV2、ZJV3 版本号;MdsApi、OesApi 是宽睿(NeutronStar)的 SDK 名;XtpQuoteApi、XtpTraderApi 是中泰证券(ZhongTai)XTP 平台的两套接口。从「读得懂」的角度,本课的 ParseMessage、ValidateChecksum、PrintRecord、BuildMessage 四步范式在所有这些协议里都通用——把 SOH 换成相应的长度前缀、把 ASCII 换成定宽二进制,骨架不变。

跨境对接的语境

把上面那条 FIX 4.4 NEWORDERSINGLE 与国内私募已知的工作流对齐:投研团队(Research)的信号产出层把决策落到一个 OrderRequest 结构上;风控网关(RiskGateway)按 PositionLimit、SingleOrderLimit、DayDrawdownLimit 三档参数过审;通过 SessionAdapter 编码成 FIX 报文走跨境通道,到 PrimeBroker 报盘端点。回报路径走 EXECUTIONREPORT,PartialFill / Filled / Canceled / Rejected 几种 ExecType 各自要触发不同的 OrderStateMachine 转移。Heartbeat 间隔通常 30s;丢两次 Heartbeat 即触发 SessionLogout 与 SessionResume 流程,这一段是 Reconnect 经理与 SessionRecovery 模块的职责。Latency 监控:TimestampHigh / TimestampLow 两段嵌进 ExtensionPack;P50 / P95 / P99 / P99point9 写到 MetricsExporter,由 Prometheus 抓取。Domestic CTP 在国内 Futures 与 Options 通道的对应物:OnFrontConnected / OnRspUserLogin / ReqOrderInsert / OnRtnTrade / OnRtnOrder,函数名不同、消息形状一致——所以本课练熟的解析与构造技法在 ZhongJin(中金 CFFEX)、ShanghaiSE(上交所)、ShenzhenSE(深交所)通道上一字不改地适用。

国内 colo 部署补遗

CFFEX 张江 colo 内的物理机通常配置 IceLakeSP 双路、四口 SolarflareX2522 NIC、独立 OnloadDaemon 进程提供透明内核旁路;订阅 ZijinXin、HuaTaiNeutron、ChinaSecurities 等几家行情供应商提供的 NormalisedFeed。日内 SnapshotResumeProtocol:丢两个序号触发 GapFillRequest,由 SnapshotServer 回送;恢复期间由 BackfillQueue 暂存。一段典型 ZjvHeader:MsgLen u16、MsgType u8、SeqNum u32、SourceId u8、CheckByte u8——和 ITCH 「header + body」的范式完全同构。本课的 BeReader 工具集(be16 / be32 / be48 / be64)原样适用;只需把 ITCH 的 6 字节 timestamp 替换为 8 字节 ZjvTimestamp。WireCapture 抓包仍由 TcpDumpUtility 完成,但 dissect 需 ZijinDissector 之类的私有插件。日终批处理通常调用 ResearchPipeline、BacktestEngine、SimulatedBookBuilder、TickReplay 几个进程,由 SchedulerOps 控制;运营层用 PrometheusExporter、GrafanaDashboards、AlertManagerCN、ChangshaJitterMonitor、ColoFanoutSensor 这些工具盯延迟与丢包。线上策略名常见缩写:MarketMaker、ArbitrageDesk、IndexFutureMM、ConvertibleMM、CommodityMomentum、StatisticalArbitrage、HighFrequencyMM、CrossExchangeStat、MultiAssetMacro,每一档延迟预算不同。

下一课

L5 把延迟故事压到内核网络栈之下。本课的 echo server 在原版 Linux 上每次往返 ~5–15 μs;L5 的内核旁路路径(DPDK、Solarflare OpenOnload、AF_XDP、io_uring)通过把内核从热点路径上移开,把这个数压到 < 1 μs。L5 还会加入硬件 NIC 时间戳做微秒级精确延迟测量,并给出把嘈杂服务器变成安静测量仪器的操作系统调优清单(isolcpus、CPU governor、IRQ 亲和性)。本课的 FIX / ITCH 解析器在 L5 收官里一字不改地继续用——只有接收路径换了。

练习

Exercise

(a) 把 TCP echo server 构建并跑起来。另一个 shell 里 nc 127.0.0.1 9999,敲一行回车,确认服务端回显;然后在会话进行时 tcpdump -i lo -w /tmp/echo.pcap -s 0 port 9999,再 tshark -r /tmp/echo.pcap -x | head -20 看线上的字节。(b) 实现一个加入 239.0.0.100:30000 组播并打印任何收到包的 UDP 组播接收方;用 nc -u 239.0.0.100 30000 或一段小发送脚本测试。用一句话说明为什么生产行情链路用组播而非每订阅方一条 TCP。(c) 实现 parse_fixprint_new_order。用 build_new_order_single() 造出本区域的 NEWORDERSINGLE,解析它、打印它。确认打印行里 ClOrdID / Symbol / Side / OrdType / Price / OrderQty 都对。然后故意把消息 body 改坏一个字节再跑;确认 checksum 校验失败、parse_fix 返回空 map。用一句话说明 checksum 算法精确长什么样。(d) 在代码里造出一个小的合成 ITCH 文件(写 10 条 'A' Add Order 记录,每条 11 字节 header + 25 字节 body,所有多字节字段为大端),写到 /tmp/sample.itch。实现 walk_itchparse_add_order,在 mmap 的 /tmp/sample.itch 上跑。确认打印的 10 行与输入数据一致(order_ref / buy_sell / shares / stock / price,price 用 price * 1e-4 解出)。用一句话说明 64 位字段为什么用 __builtin_bswap* 而非 htonl / htons

提示

(b):一个包、N 个订阅方——在交换机层,交易所的带宽与不同消息条数成正比,而不是订阅方数。

提示

(c):checksum 把从消息起始一直加到(但不含)10= 标签为止的每一字节求和,取 mod 256,再渲染为 3 位零填充 ASCII。