高性能TCP加密通信服务器。
High performance TCP encrypted messaging server.
关键字
- Modern C++(17/20/23)
- 并发、异步
- Boost.Asio
- Thread-per-core+线程池
- Post-Quantum Cryptography
巧夺天工
[ ]
阴阳相生
coroutine 的最大魅力在于优雅地让渡。
筑基:struct Msg及消息结构
read_header和read_body
堆上的“栈溢出”
你说得对,但是 coroutine 又不在栈上,怎么栈溢出
大道至简
玲珑妙妙屋之基于
std::atomic<Connstate>的状态机
ConnState设计及转换
每个Connection对象都有一个private成员ConnState,表明该连接当前的所处状态。
既然在现代C++中,ConnState自然应为enum class,其定义如下:
1
2
3
4
5
6
7
8
9enum class ConnState: uint8_t
{
Connected,
Handshaking,
Established,
Authenticated,
Closing,
Rekeying,
};
其中Rekeying又是一个挖了之后无限期咕的坑,玲珑传统艺能
解析:
- 新建立连接都会进入
Connected状态;
- Client发起第一次Handshake后进入
Handshaking状态;
- Client发起第二次Handshake后进入
Established状态(因为此时会话密钥已经能被导出);
- Client通过身份验证后进入
Authenticated状态,亦可主动登出回到Established状态;
- 出现致命错误/非致命错误数达到上限,Server关闭该连接时进入
Closing状态。
不同阶段的特性如下:
Connected/Handshaking时,双方的信息都是以明文形式传输的(因为此时安全对称信道尚未建立,双方只是在进行密钥交换)。
- 进入
Established/Authenticated后,双方信息都会加密后再封装一层进行传输,具体参见struct Msg中消息体结构的实现
明枪暗箭
明枪尚不易躲,暗箭更为难防。
异常处理的基本思路依然是C/C++的“老三样”:
- C-Style的返回值/函数引用传递错误信息;
- C++语言既有的异常处理模型;
std::expected为代表的新秀。
TiaoMeng
普渡众生——速通std::expected
In case that someone hasn't used, or even hasn't heard of
std::expectedyet
std::expected是现代C++中 sum-types
的代表作之一,其是一个类模板,声明为:
1 | template<class T, class E> |
其中 T是任意 object 类型或 void
;E 是任意 object 类型。
除引用外,绝大部分变量类型和指针类型都是 object。
T
在异常消息发送上,有诸多层面的考量:
- 由于“异常”触发时,对称加密信道的建立状态不确定:
Established及之后,异常信息是否应加密后发送(仍和其余消息一致,先编码为json格式,形如{"error": "Incorrect schematics"})?
- 对于偶发的非致命错误(如部分 parse error、action
error等)是否应设置有限的容错机制(错误次数大于某阈值后再断开连接)?如何对不同错误按其致命与否进行分类?错误计数器的重置条件如何设置?
- 若因出错而需断开连接,管道中剩余的消息应如何处理——继续尝试发送还是强行断开连接?若断开连接时还有错误消息,其又应如何发送?
经数轮迭代后,TiaoMeng 设置的错误处理模型如下。
上层接口:三大 helper
设置了三个 helper
函数:send_error、send_raw_error和error_close,其签名分别形如:
1
2
3
4
5
6void send_raw_error(std::string_view err, CloseMode mode = CloseMode::Graceful);
[[nodiscard("Do not discard send_error's value: caller is responsible for co_return upon this function returning true to prevent connection leakage. Use std::ignore or void cast for explicit schematics.")]]
bool send_error(std::string_view err, CloseMode mode = CloseMode::Graceful, bool force_close = false);
void error_and_close(std::string_view err_text);
其中,
send_raw_error直接发送明文错误消息(且没有json编码),同时立即关闭连接(“defaults to force close”);send_error发送加密+编码后的错误消息,force_close置false时为非致命错误,此时failure_count调用其成员函数自增,同时返回bool指示其是否已经到达fail阈值,该信息通过send_error的返回值继续向上层传递。
其亦为此处放了一长串
nodiscard("reason")的理由——send_error的调用者必须保证在底层 failure_count 到达上限时主动从当前函数(无论是普通函数还是coroutine)中(co_)return。
因为send_error在到达上限时会调用close(mode),此时语义上连接已经失效,若该函数/coroutine未及时退出甚至像就会导致read_body/read_header一样还在继续打阴阳相生的太极std::shared_ptr<Connection>引用计数不归零而继续存在(Connection没有析构)。
不巧的是此时Server已经将该Connection从std::unordered_map中移除,因此其无法被继续追踪,等于发生了 Connection Leak。
若
send_error的调用者能保证调用后立即(co_)return,亦可以static_cast<void>等方法显式忽略之。
但玲珑显然还是更喜欢std::ignore——其难道不值得推广咩?语义更清晰且更易读,同时其右侧操作数约束了不能为void——至少在 TiaoMeng 中实践如此:
1
2
3
4
5
6
7
8 switch(semantic)
{
//...
case MsgSemantic::Session:
std::ignore = send_error("Session management not implemented");
co_return;
//...
}
error_and_close则是更高一级的接口套娃,又名“代码精简大法”,其一行lambda式的本质就是this->is_established() ? (void)send_error(err_text,CloseMode::Graceful,true) : send_raw_error(err_text, CloseMode::Graceful);
异常计数:嵌套类Connection::FailureCounter
FailureCounter只是简单包装了一个std::atomic_bool<size_t>的计数类,调用其“自增”方法可以返回其当前状态(是否需要
close逻辑:close、close_async与CloseMode
对于出错断开连接的情况,由发起方调用close函数,其将当前状态置为Closing(通过std::atomic+std::memory_order_seq_cst避免竞争),随后通过net::co_spawn调用close_async异步关闭该连接,避免关闭过程中阻塞该线程(此时该线程仍可以处理其它连接)。
close的实现:
1
2
3
4
5
6
7
8
9
10
11
12void Connection::close(CloseMode mode)
{
if(this->state.exchange(ConnState::Closing) == ConnState::Closing)
{
return;
}
net::co_spawn(strand,
[self = shared_from_this(), mode]() -> net::awaitable<void>
{
co_await self->close_async(mode);
}, net::detached);
}
函数中出现了新的常客CloseMode,其为一个只有两个取值的enum class反正现在是这样,原来还百花齐放来着,对应close_async中不同的处理逻辑:
1 | net::awaitable<void> Connection::close_async(CloseMode mode) |
CloseMode::Graceful会尝试调用write,后者则会根据缓存的消息队列调用net::async_write发送消息,直至消息队列为空;CloseMode::Immediate则会立即通过清空消息队列+调用底层
socket
的部分接口函数取消+关闭连接,同时保证了其noexcept(true):
1 | void Connection::shutdown() noexcept |
实际上
write的调用链为Connection::write->Connection::write_with_timeout->net::async_write,其中间件的解析下回分晓。
“归元”的其它原子变量
天机道藏
Kyber768: NIST-PQC
两轮KEM:Diffle-Hellman Plus
?
std::expected等 sum-types 作为现代C++组件,相较类似的、更传统的实现能实现更清晰的语义、更好的可读性与拓展性。
御守——简单而不平凡的实例
bool check_password(std::string_view password);
看似平凡的检查密码合规性函数,其在后续拓展时就经历了遭遇瓶颈-迁移至
sum-types 的过程。
具体地,最早的check_password作为一个近似于
placeholder 的存在,只要求密码长度不小于8位:
1 | bool check_password(std::string_view password) |
但随之而来的一个问题是,不同的 caller 对其调用返回
false 后的错误消息生成、处理逻辑可谓
万象霜天:
grep实录
1 | auth/auth_manager.cpp: if (!auth::check_password(password)) |
其带来的另一个问题是,若此后希望拓展密码检查的逻辑(添加更多约束、根据约束生成TODO式的自定义信息),由于目前生成错误信息的代码被下移至调用方,其维护开销会非常大或者摆烂,留下望着不明所以错误信息而怅然若失的user。
而由于密码校验成功时不需要携带额外信息,因此std::expected<void, std::string>便成了最佳选择:
1 |
镜花水月——std::optional的夭矫空碧
std::optional
碧海归元——从智能指针看 ownership
使用
std::unique_ptr等智能指针的意义不仅在于内存安全,更在于显式地明确所有权(ownership)。
回顾一下RAII原则:一个类的对象应当在其构造函数中申请对应的资源,并在析构函数中予以释放。
其中蕴含了非常重要的“所有权”逻辑:
若某资源(文件指针、堆内存等)是被某个对象
拥有(owned)
的,那么只有该对象(或共同管理该资源的同类对象)可以释放之,且这个语义应当得到强化。
经文以述难以见明,仍以具况为佳:
1 | void fun(int* ptr); |
看似平平无奇的fun函数,但此处的ptr难免让人心存顾虑——fun究竟是否“拥有”ptr指向的内存?
这点与
const语义仍有差分——而且即使是附加顶层const的const int*,delete ptr;一类释放内存的语句依然是合法的——但显然这不符合“语义约束”之目标。
“所有权”传递有几种方式:
- 对于独占资源,依靠移动语义(语言实现上,移动构造/移动赋值函数)转移所有权;
- 对于共享资源,可以直接复制所有权,但需要底层计数器的支持(以实现RAII);
- 亦可仅传递一个不拥有所有权的“观察员”,其可视情况“申请”所有权。
上述三个例子在智能指针中对应的实践就分别是
unique_ptr、shared_ptr 和
weak_ptr。
unique_ptr实现明确语义的原理也非常简单,即禁用复制构造函数来实现纯移动语义,在函数中通过传值引用来约束所有权的转移:
1 | void fun2(std::unique_ptr<int> own); |
第二行过后,ptr管理的的指针所有权就完全交给了fun2,原函数中ptr置空,因而从语义层面同时避免了内存泄漏和
double free 的问题。
Hint:千万不要写类似于
delete ptr.get()之类的语句,若确需中道override,应使用ptr.release()。
启示之一就是智能指针在兼容C样式api的时候需要考虑语义兼容的问题,注释/断点先置为佳。
而对于shared_ptr,其用处在于多个对象共享一个资源,同时保证该资源可以被正确地释放:
1 | net::co_spawn(strand, |
除了read_header以外还有其它函数需要Connection*指针(应用此对象),同时希望所有Coroutines结束后释放掉该Connection的资源,因而选用shared_from_this(封装shared_ptr类)为上策。
而与shared_ptr相伴的还有weak_ptr,应用上其语义最大的不同在于是否
需要 “使用”其对应的资源,即是否 需要
这个所有权。
1 |
特辑:std::default_delete
此前,玲珑还未尝深入探索过智能指针第二参数的奇妙之境。
不知有多少人留意过,std::unique_ptr还有一个鲜有问津的第二(带默认值的)模板参数,参见unique_ptr的类模板声明:
1 | template<class Ty, class Dx = std::default_delete<Ty>> |
Dx默认值是std::default_delete<Ty>,即恰如其名地Ty的默认析构函数。
若Ty不是class/struct,则该默认“析构函数”实际上为空(即free/delete时不会有任何其它行为),否则其对应其析构函数Ty::~Ty()。
尽管default_delete可以应对绝大多数智能指针的使用场景,但依然有二者被遗忘一隅:
若对于非
class/struct的类型,依然希望其能依托智能指针实现类似“析构”的操作(尤其对于很多C-Style的指针等)当如何?
若希望更改其执行的析构函数(如自定义了内存池或非虚析构函数下的类继承)呢?
其实default_delete的作用机制也不复杂:
回顾C++的fundamentals,尽管筑基时学过析构函数在对象超出生命周期时被自动调用,但作为类的成员函数之一,任意场景下,其亦可如同一个普通成员函数一样被调用:
1 | class SomeClass |
根本原因是所谓“析构函数”只是另一个会“有条件地触发”的成员函数
这也是C++中极不提倡使用。goto的原因,scope乱飞警告
虽然C++中没有类似于Python的显式thisC++23终于有了deducing
this,
好耶,但从编译器实现而言,所有非静态成员函数的“隐式”第一参数本质上都是本类的引用:
1 | class SomeClass |
编译器生成的(仅供参考,原理相同):
1 | void SomeClass::fn(SomeClass& __this,int x); |
感兴趣者可以了解一下C++特有的调用约定 thiscall,即调用非静态成员函数时将相应对象作为第一参数传入。
这种调用方式其实也能在一些wrapper中初见端倪,比如同时适配普通函数和非静态成员函数还有lambda的std::invoke:
1 | SomeClass obj; |
后一行和obj.fn(1)是等效的。
说远了,回到析构函数的调用:
无论是栈上还是堆上的变量,无论是在其生命周期内主动调用析构函数,还是故意不调用其析构函数(比如operator new分配的内存使用free释放),这个调用析构函数的行为本身不会有任何影响。
但若析构函数体内涉及资源的释放,则可能存在 double free
的问题虽然很多带有fail-safe组件的类也不一定,比如非常友善地帮你把:ptr置nullptr的std::vector
1 | SomeClass::~SomeClass() |
联立一下RAII原则的本质,其实就是某类分配的资源需要“有个函数”去清理八面玲珑,思维极为跳跃,而Dx这一模板参数即提供了一些“外部函数”执行清理任务的窗口。
unique_ptr的构造函数中有一个颇为有趣的重载:
1 | template<class Ty,class Dx> |
只要requires {std::invoke(__fn, __ptr);}
为true,这个构造函数就可以成立(即通过编译),效果就是在该unique_ptr析构时,对其管理的指针调用该函数。
默认该函数是该指针类的析构函数,此处只不过是覆写之。
抛开各种花里胡哨的demo code,直接上一个实用的用法:
1 | auto ptr = std::unique_ptr<FILE, decltype(&fclose)>(fopen("a.txt", "r"), fclose); |
一行wrapper,直接实现了对FILE*这一让人又爱又恨的C
Legacy得到了一个RAII wrapper。
由于默认模板参数的存在,声明变量时的两个模板参数都需要手动指定(不能直接丢一个
fclose过去让它推导,因为默认参数优先级高于推导hint:可以试试重载wrapper)。
井井有条——std::atomic与std::memory_order
C++中,程序合法(通过编译不会出现UB、栈溢出/ double free /越界等现象导致
abort)是一方面;程序的正确性(执行流程、结果符合预期)则是另一方面。
std::atomic只能保证对变量操作的原子性(即防止了互斥的读写操作同时执行,一般是多个写操作)。
C++中,编译器和CPU可能会将这些指令顺序进行重排,以达成执行层的优化经典pipeline,但其不保证指令顺序的语义正确(只能保证内存访问合法)。
具体地:
1 | int x; |
若thread_a和thread_b并发执行,则由于
data race 会产生包括但不限于直接abort的UB。
而若将x的声明改为
1 | std::atomic<int> x; |
这也只能保证程序不会出现UB,但众所周知undefined并不等于unexpected,程序的执行结果依然不确定——其是由于并发的本性所致。
那如果编写的代码“看似”可以完成顺序执行的语义呢?
1 | std::atomic<int> x; |
x被load了两次,但第二次load的结果就已经不一定是1了(中途可能有其它变量对其进行了修改),即由于“缓冲”的存在(实际上是由于cache/pipeline优化的指令重排所致),x在其它线程中的修改不一定立即对本线程可见,比如编译器可能会生成这样的机器代码:
1 |
对于另一个线程,其对x的修改可能会由于inline
cache的优化而被暂存于某寄存器中,此时其对该变量的修改是(对其它线程)不透明的。
std::atomic对多数 trivial types
的特化都支持一些常用的赋值、运算操作符以供操作便利,但其本质上还是底层一些更有趣函数的wrapper。
1 |