2024 总结

总结一下 2024 年写码的一些经验和收获。

算法

没有什么突破性的创造,世上比我高明的人太多,catchup 都是很大的问题。记录一下遇到的高端操作好了。

Buffer Pool & Ring Buffer

低延时操作中不能在使用时 new 对象,因为这会导致底层 sbrk 申请内存空间,且得到的对象碎片化特别严重,从而延时飙升。习得的操作是初始化时申请一个内存池,自己的对象放在池中。

Ring Buffer 解决信息传递的生产者-消费者问题。只有一个读者和一个写者时,维护读位置和写位置可以在不加互斥锁的情况下实现消息队列。但是此过程依然需要保证 memory barrier,即进程在写入和读取的先后顺序上始终保证。此过程可以用 atomic 变量实现,也可以直接插入 rmb 或者 wmb 这样的汇编。在 x86 架构上原子变量本来就时保证全序的,不必纠结到底是什么屏障最终都一样。原子变量的延时高于单纯的 mb,应该是有别的附加功能在里面。

OS

低延时与CPU调度

如果程序被调度下cpu,将会带来很大的上下文切换的延时。所以程序在运行时需要绑定CPU核心。最简单的方式是 taskset

1
taskset -c 0,1,2,3 <your_program>

这表示程序将在 0-3 核心上运行。或者也可以只绑定一个核心。使用 lscpu -e 或者 lscpu 查看cpu信息。

即使绑定了单一核心,进程依然可能被调度下cpu让给其他进程。要想cpu调度时永远不抢占,需要隔离cpu核心,再把进程放上去。在 /etc/default/grub 中找到 GRUB_CMDLINE_LINUX_DEFAULT 行,并添加 isolcpus 参数

1
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash isolcpus=4,5,6,7"

然后更新 grub

1
2
sudo update-grub
sudo reboot

验证是否成功

1
taskset -cp 1

输出不包括 4,5,6,7 即成功。

IRQ balance 设置问题还没考虑

应当关闭超线程。

重新思考万物即文件

零拷贝指的是数据不要到用户层转一圈。mmap或者sendfile都考虑的是这个问题。

mmap是Linux提供的一种内存映射文件的机制,它实现了将内核中读缓冲区地址与用户空间缓冲区地址进行映射,从而实现内核缓冲区与用户缓冲区的共享。

sendfile直接指定文件描述符和套接字,发送指定大小的数据。但是这里从内核缓冲区到套接字缓冲区仍然可能有cpu拷贝。如果硬件支持DMA,此操作不用cpu完成,而是直接从内核缓冲区DMA数据到网卡。

读文件时mmap未必快,且占用真实内存越来越大。经过实验,python中使用 with open(file) as f 的读取方式效率是最高的,且代码最简洁。

profiler and core dump

两个很重要的需要root权限的工具。前者用于监控程序的开销,后者在程序跑飞时查看原因。回头补上。

1
2
ulimit -c unlimited   # 允许生成 无限制大小 的 core dump 文件
echo "/$(pwd)/core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern # 希望将 core dump 文件存储到程序的当前工作目录中

在 core dump 时,使用 gdb 调试:

1
gdb <exe_file> <dump_file>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Core was generated by `./err'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x00005604cbfd41c1 in main () at ./error_codes.cpp:15
15 std::cout << *ptr << std::endl; // 解引用空指针,程序会崩溃
(gdb) list 10
5 * @LastEditTime: 2025-01-15 11:01:30
6 * @FileName: error_codes.cpp
7 * @Description:
8 *
9 */
10 #include <iostream>
11
12 int main()
13 {
14 int *ptr = nullptr; // 空指针
(gdb)

C++

Log 技巧

如果程序要打日志,则字符串加IO操作必然慢到爆炸。本人还没能搞定一套特别舒服的日志库,所以只能论述一下原理。

首先,你的写日志一定是不能和核心逻辑在一个线程上运行的,起码得保证这个日志是核心逻辑没事干了再去处理。这里是开线程还是进程,怎么处理异步、线程池、缓冲区,说实话我还没想明白。

其次,调用日志记录时,最好把所有的内容都放在单独的参数中,最终的字符串放在写日志的逻辑里生成。这里参考一下 printf(),搞一个可变参数列表接收参数,回头再处理。

最后,日志库怎么保证线程安全?很显然写文件不能是我一条写到一半中间插入了另一条,对写入操作加锁可以实现,但是有没有更好的解决方案?

只能说 C++ 的开源日志库都乱七八糟,最基本的问题都不能很好回答。网上论坛动不动捧一踩一,反正也未见得完美。
计时。精确的clock使用rdtsc`。

刚看到一条外国 prop 大佬的贴子。高频的交易 FPGA+share memory。实盘上没有book management,O3 + 没有log,log放在replay处理。非实时的risk control。

Class

几点重要事项:

  1. 父类中是虚函数,子类继承时自动时虚函数。子类继承时最好写上override关键字。

  2. 析构函数是虚函数

    如果基类的析构函数不是虚函数,删除一个指向派生类对象的基类指针时,只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类的资源没有得到正确释放,可能导致资源泄漏或未定义行为。

  3. 静态成员函数 static
    static 关键字的类函数无需实例即可调用(使用类名)。

  4. 不要 memset 一个类的实例,虚函数表可能被破坏!

初始化

对于聚合类型(没有用户定义的构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、虚函数 和 成员不使用访问控制(如 private 或 protected)的 结构体 或 类)可以使用命名初始化指定部分变量的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

struct MyStruct {
int a;
double b;
char c;
};

int main() {
// 使用命名初始化初始化聚合类型
MyStruct s{.a = 10, .b = 3.14}; // 只初始化 a 和 b,c 会使用默认值(0)

std::cout << "a: " << s.a << ", b: " << s.b << ", c: " << s.c << std::endl;
return 0;
}

这里初始化列表的变量顺序不可违背。

接口与实现分离

暴露给用户的类只是封装,类里面有一个Impl类的指针。这个Impl类只有前向声明(forward declaration)。文件夹下的 person.hpp personImpl.hpp person.cpp personImpl.cpp 展示了基本的概念。用如下命令编译为动态链接库:

1
g++ -fPIC -shared -o libperson.so person.cpp personImpl.cpp

使用时只要引入 person.hpp 并链接 libperson.so:

1
g++ personuse.cpp -L. -lperson -o person

好处多多,自行揣摩。

一点思考:这里的参数能不能完美转发?
Hint:完美转发参数的要求是什么

两点思考:只是传递引用,在哪个类的参数定义为引用?

CRTP

CRTP(Curiously Recurring Template Pattern) 主要的特征是:类 Derived 作为模板参数传递给其基类 Base,即 Base<Derived>。它可以为代码带来一些重要的优化或能力,如静态多态性、类型安全等。

通过强转指针类型,可以调派生类的函数,如果函数为 static,则可以直接调用模板类的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* @Author: Zhuoyuan He
* @Date: 2025-01-15 11:31:38
* @LastEditors: Zhuoyuan He
* @LastEditTime: 2025-01-15 11:40:35
* @FileName: CRTP.cpp
* @Description:
*
*/
#include <iostream>

// 基类模板
template <typename Derived>
class Base
{
public:
// 通过 CRTP 访问派生类的功能
void interface()
{
// 直接调用派生类的实现
static_cast<Derived *>(this)->implementation();
Derived::static_implementation();
}

// 默认实现或接口
void defaultImplementation()
{
std::cout << "Base default implementation" << std::endl;
}
};

// 派生类
class Derived : public Base<Derived>
{
public:
// 派生类自己的实现

static void static_implementation()
{
std::cout << "Derived implementation" << std::endl;
}
void implementation()
{
std::cout << "Derived class implementation" << std::endl;
}
};

int main()
{
Derived d;
d.interface(); // 通过 CRTP 调用派生类的方法
return 0;
}

此处虽然接口和实现分离了,但是并没有产生虚函数。调用时几乎没有额外开销。

函数指针与包装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

// 定义一个普通的函数,接受两个整数参数
int add(int a, int b) {
return a + b;
}

// 使用函数指针作为参数,传递一个返回 int 类型、接受两个 int 参数的函数指针
void executeFunction(int (*func)(int, int), int x, int y) {
int result = func(x, y); // 调用传入的函数
std::cout << "Result: " << result << std::endl;
}

int main() {
// 传递函数指针给 executeFunction
executeFunction(add, 5, 3); // 调用 add(5, 3)
return 0;
}

std::bind 可以将函数和参数“绑定”在一起,返回一个可调用的对象。这个对象可以被传递给线程池中的线程。std::function 用于存储这个可调用对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <thread>
#include <functional>
#include <vector>
#include <future>

// 一个简单的函数
void printMessage(int a, const std::string& msg) {
std::cout << "Number: " << a << ", Message: " << msg << std::endl;
}

// 线程池任务接口
void threadPoolTask(std::function<void()> task) {
task(); // 执行任务
}

int main() {
int number = 42;
std::string message = "Hello from thread";

// 使用 std::bind 绑定函数和参数
auto boundTask = std::bind(printMessage, number, message);

// 将绑定后的任务传递给线程池中的线程
threadPoolTask(boundTask); // 在此示例中直接调用,而在实际应用中应传递给线程池

return 0;
}

STL

遍历一个 STL 的过程中不要轻易删除元素!!!

其他

完美转发、线程池、异步操作、future 关键字等等。八股永远学不完,感觉目前水平也还没能够用上,函数中有引用能够处理干净,偶尔有个 template 都不错了。这条路还有很长要走。

Python

最大的教训就是不要有自己编译安装的执念。都python了就不要在意编译时是不是能多一个更适合当前机器的参数了,搞到最后缺一个库递归安装最后把自己爆了简直让人破防。如果是自己用,直接 conda 搞个env是最方便的。

如果是系统缺包,直接 sudo 安装在 ubuntu 22.04 之后就不行了,所以要在最后加上 --break-system-packages 强行安装。这里常见的问题是 docker 需要降级 requests 包:

1
2
sudo pip install requests==2.31.0 --break-system-packages
sudo pip install XXX # 回头补上

jupyter 虽好,亦有问题。

  1. 如果修改了底层的package,重新运行 cell 未必能加载新的,需要重启内核。
  2. pybind11 生成的package运行时,存在大量输出直接爆output的情况。这中间的IO缓冲肯定有问题但是我找不到,直接转成 .py 文件就好了。

Web 相关

都用 Python 了就不要太在意性能,而应当注重结果的可读性以及时间成本。快速可视化展示的神器。

flask 适合用于 Web API 的交互,处理请求并作出相应。请注意 API 的无状态性,响应结果不要依赖于前一次的请求

flask 的程序不要死的太快,死了也不要让使用者有察觉或者影响下一次调用。不要直接用 python myapp.py 运行程序,而要安装 uwsgi 来守护。控制一下每个进程最多处理多少个请求就重启,别内存炸了。

可视化界面使用 streamlit 展示。完全不用考虑前后端,针对特定的 URI 安排访问时后台生成数据的逻辑,安排交互按钮需要实现的反应,把内容直接展示出来就好了。依然使用守护进程,不要突然挂了。至于后台是调本地文件还是查找数据库完全分离不受限。

Gtest& Pytest

两个测试框架

千万不要把判定语句放在被多次调用的函数或者宏里面,而是直接放在返回值位置,否则即使告诉了哪一行出错,依然不知道具体是哪一步出了问题。GDB 是个好东西,python 也是可以用 GDB 调试的,这部分能力我太乐色,日后想办法提升一下。

奇技淫巧与其他

延迟可以直观理解吗

总是说低延迟,多少算低?有交易公司说自己可以做到 ns 级相应。

考虑一个标准的千兆网络,其传输速度大约为 120 MB/s。说明其传输 120 Byte 的数据需要 1 us。一条行情,包括报文头部、标的信息、成交信息或者snapshot信息,大约就在这个数量级。接收到这个信息都要 1000 ns,下单估计也是这个数,还假设了没有传输的时间。简单估算,cpu加减的时间在 ns 数量级,如果有什么奇技淫巧可以线速度处理的话这部分倒是真纳秒级别(那就是纯简单逻辑发单)。但是你估算几个不平衡因子,加上EMA窗口在总体加权一下,怎么着量级也在微秒了。因此,总体来说报单响应在 us 级别我觉得是合理的,优化时也应当考虑这个量级的优化。

之前从未发现消息发送的延迟都在 us 级别,这倒是给我很大震撼。这样来看,好的交易系统应当选择直连且带有网卡加速,万万不可依赖转发的信息。
OK 看到有25G低延迟光纤网卡,延迟可以缩小一级。DPDK或者加速卡,直接把数据DMA zero-copy,这边2025年期待一下能不能看懂。
零拷贝不是0次拷贝,是内核缓存区到应用缓存区0次拷贝

RDMA Kernel Bypassing

proxy

Linux下终端设置代理

1
2
export http_proxy="http://10.1.100.130:8080" 
export https_proxy="http://10.1.100.130:8080"

windows终端设置代理
好像没用 回头看看怎么回事

1
2
3
4
5
6
set http_proxy=http://10.1.100.130:8080
set https_proxy=http://10.1.100.130:8080

# 如果你使用的是 PowerShell,则可以用 $env 来设置环境变量:
$env:http_proxy="http://10.1.100.130:8080"
$env:https_proxy="http://10.1.100.130:8080"

换行符的坑

不要随便认为换行就是 \n,有可能是 \r\n。在一个字符串中,希望得到 content-type: 之后这行的内容,不要按照 \n 切割,否则都不知道怎么死的。参考的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
std::string get_type(const std::string &input)
{
// 使用正则表达式匹配 content-type: 后的内容,直到行尾
std::regex pattern(R"(content-type:\s*([^\r\n]+))", std::regex::icase);
std::smatch match;

if (std::regex_search(input, match, pattern))
{
return match[1].str(); // 返回第一个捕获组,即 content-type 后的内容
}
return ""; // 如果没有匹配到,则返回空字符串
}

文件命名的坑

千万不要随意起文件名,尤其不要让文件名和系统中存在的文件名一样,比如 tuple.cpp numpy.py 之类的。不然在import的时候可能就会调到自己的文件,且此问题较难发现!!!

CMake

如果新版本的 gcc 链接了老版本的 gcc(GCC 4.x) 生成的 so 文件,在 std::string 的处理上可能会有问题。最常见的是 std::string 作为返回值的函数直接找不到了。

GCC 5 版本之前,C++ 标准库使用的是旧的 ABI(应用二进制接口),它与 C++11 标准中的一些新特性(如 std::stringstd::vector 的实现)不兼容。为了支持 C++11 和之后版本的特性,GCC 引入了新的 ABI。从 GCC 5 开始,默认使用新的 ABI

CMakeLists.txt 中,指定使用旧版本 ABI

1
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_GLIBCXX_USE_CXX11_ABI=0")

贴一个基本的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cmake_minimum_required(VERSION 3.10)
set(PROJECT_VERSION_MAJOR 0)
set(PROJECT_VERSION_MINOR 2)
set(PROJECT_VERSION_PATCH 3)

project(project_template VERSION ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH})
add_compile_definitions(MODULE_VERSION="${PROJECT_VERSION}")

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -D_GLIBCXX_USE_CXX11_ABI=0")
set(CMAKE_CXX_STANDARD 17)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # 生成编译命令的文件

add_definitions(-DFMT_HEADER_ONLY)
# 添加 fmt 的路径
add_library(fmt INTERFACE)
target_include_directories(fmt INTERFACE third_party/fmt/include)

include_directories(include)

add_subdirectory(proj_sub)
add_subdirectory(test)