高性能TCP加密通信服务器。
High performance TCP encrypted messaging server.
关键字
- Modern C++(17/20/23)
- 并发、异步
- Boost.Asio
- Thread-per-core+线程池
- Post-Quantum Cryptography
巧夺天工
阴阳相生
coroutine 的最大魅力在于优雅地让渡。
筑基:struct Msg及消息结构
struct Msg的定义如下:
1 |
阴阳相生——read_header和read_body
读信息上因为涉及到单次读取长度、逻辑处理维护等问题,自然地,header/body分离处理是一个明智之举。
回顾Msg的结构形如:len(4) | type(1) | payload(len)
因而read_header只需要读5个Bytes,校验长度和类型信息合法,随后读取len个Bytes即可。
这种结构的优点在消息头开销较小,缺点为缺少一些校验字段(如填充一些字节标志开始/结尾等),冗余度稍显欠缺。
而这两者实际上是“阴阳相生”般循环往复的关系——read_header后立即开始read_body,后者完成后开始下一次的read_header,保证服务端对客户端的可达性。
同时,解析出错(长度不合法等)、errc置位、超时等将被视为致命传输错误而立即断开(重置)连接。
本服务器封装的网络层协议是TCP,因而不应再设置一个对可靠管道出错的容错层。
1 |
一箭双雕:Timeout和operator||
“相生”模型的最大优点在于,其蕴含了控制连接超时的逻辑——
不论是因为连接过载/通信不稳定等pipe问题,还是客户端发送请求超时“睡着”,都可以被蕴含在read_head/read_body动作的超时计时器里。
其中,net::experimental::awaitable_operators::operator||是一个非常奇妙的二元运算符op重载。
其可以用于dispatch两个(乃至多个)线程,极为便利地封装了并行+竞争飙车机制——使用operator||的所有操作中一个co_return,其它所有线程自动取消。
同时其返回值类型为std::variant<Ty1, Ty2, ...>,Ty1为第一个线程的返回值类型(都指net::awaitable内嵌的类型),后同;使用index()成员函数即可判断哪个线程执行完成。
该operator||和timeout机制可谓天作之合——把基础的write逻辑打包,再随缘搓一个timer_op,连接+判断+返回处理一条龙:
1 | net::awaitable<std::optional<IoResult>> write() |
堆上的“栈溢出”
你说得对,但是 coroutine 又不在栈上,怎么栈溢出
Coroutines的最大魅力即在于,通过 Coroutines 语句产生的实例是一个状态机(State Machine),而非普通的栈结构。
- Coroutine
本质上是一个状态机,其所需的所有信息(局部变量等)全部通过
operator new在堆上分配,“栈帧”(stack frame) 这一概念只对当前活跃的 coroutines 有效;其向外传递变量的方式是通过 Coroutine 对象的各类方法(接口)实现。
Coroutine 的对象类必须符合
std::coroutine_traits的相关约束。
co_await、co_yield会将当前 coroutine 的状态保存至堆上,随后释放栈帧空间;在 一定条件 下其恢复运行时,再从堆中将局部变量等信息载入。
co_yield的唤醒偏主动(下一次“调用”,常用于generator等lazy operation),co_await的唤醒偏被动(等待另一个 coroutines 执行完成,但二者都是异步执行)。
对比一下:
1 | std::generator<int> fib() |
万象霜天
Strand-per-connection
Thread-per-core与线程池
Server与Connection的纠葛
大道至简
玲珑妙妙屋之基于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>的计数类,调用其“自增”方法(对外接口record())可以返回其当前状态(是否超过限制而终止计数)。
其中为了将现代C++提升维护性的优势发扬光大广义上的炫技,在record函数声明前同样放了一长串的[[nodiscard(reason)]]:
Hint:各类作用于类、函数、
enum[class]等的 attribute 应放置在其对应的声明(declaration)前,而非定义(definition)前(因为相关生成/屏蔽Warning的操作是在编译而非链接阶段完成)。什么?内联声明咩?你流皮awa
1 | [[nodiscard("record() returns whether count has exceeded max failure after pre self-increment.")]] bool record(); |
碎碎念:提升接口维护性、健壮性一类的 attributes 中,私以为应用较多的是
[[nodiscard("reason")]]和[[fallthrough]]。
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
CTF-Crypto手试图夹带私货现状
两轮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::expected<void, std::string> check_password(std::string_view password, std::string_view ref_username) |
其优势在于拓展性、维护性极强(需要增删改约束条件时只需要动一下count_chart_fn即可),且返回的异常字符串极为生动形象。
虽然每次看到拼接字符串/各种range的逻辑时还是抱怨std::views::concat何时才能上线,zzzZZZ
镜花水月——std::optional的夭矫空碧
std::optional是C++17引入的类模板(三大
sum-types 之一)。
optional较expected功能更少(有点像std::expected<T, void>,虽然后者是
ill-formed
的所以另一种理解方式是合法的偏特化(bushi),但自然也更为轻量级,适用于偏中-低层的、不需要通过函数接口返回异常原因的函数。
在 TiaoMeng
中,std::optional和std::expected运用一样广泛:
1 |
碧海归元——从智能指针看 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。
对std::atomic变量真正进行赋值读/写的是load和store二者:
1 |
相序
memory_order是std下的一个enum class,其成员有五:relaxed、acquire、release、acq_rel、seq_cst。
还有一个
consume因编译器实现困难、不稳定等原因于C++17起已记为弃用,可忽略之(既有实现建议更改为acquire)。
为便于使用std中还有把对应第二个域解析运算符替换为下划线的五个变量,形如std::memory_order_relaxed
其意分解如下:
relaxed:最弱的约束坐和放宽。对该原子变量的操作(读/写)可以被任意地重排,单个线程的读/写操作不保证对其它线程立即可见;但程序执行的最终结果保证一致。常应用于对实时同步要求低的场景性能榨汁机,放权给编译器和CPU优化,如全局metrics计数器。
acquire/release:最常用的二者,用于实现 happens-before(先行) 语义,分别只能用于含有读/写操作的函数上防止什么奇怪的。happens-before 语义的内涵为var.store(std::memory_order::acquire),当然不会报compile error,但会报warning并回退到seq_cstrelease(写完释放)一定会在acquire(读前获取)之前,即对该变量的一个写操作和对其下一个读操作会被 同步(synchornize),保证了跨线程acquire的数据一定是release后的结果。
acq_rel:相当于acquire+release,适用于需要既读又写的场景,如自旋锁的简单实现但为什么不用、std::atomic_flagfetch_add(自增)。seq_cst:一致性顺序,最强约束直观上看似乎没有限制一样,无形的枷锁,即涉及该变量的所有读/写操作不会被重排(其邻域指令和其相对顺序不会发生变化),好像程序正在按字面顺序(单线程式地)执行一样拾回了并发下失效的公理。和acq_rel的最大区别在于acq_rel在不违反读/写语义的情况下仍然允许重排,但seq_cst不允许;其有利于对程序的debug等,但大幅牺牲了程序效率,极少用于Release中。
番外:acq_rel和seq_cst之争
acq_rel和seq_cst的差分直观上并不明显——二者区别之一在于,“先行”语义只能保证对单个变量的相关读/写顺序正确,但对依赖多个变量(且分别使用acq/rel)的顺序则不确定:
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
35std::atmoic<int> x{0};
std::atomic<int> y{0};
std::atomic<int> z{0};
void t1()
{
x.store(1, std::memory_order::release);
}
void t2()
{
y.store(1, std::memory_order::release);
}
void t3()
{
if(x.load(std::memory_order::acquire) && !y.load(std::memory_order::acquire))
{
++z;
}
}
void t4()
{
if(y.load(std::memory_order::acquire) && !x.load(std::memory_order::acquire))
{
++z;
}
}
int main()
{
std::array<std::jthread, 4> v{t1, t2, t3, t4};
}
四个thread并发运行,看似 happens-before
的语义已通过acquire/release
妥善建立,但匪夷所思的是,最后z的值依然可能为2(即单次执行中,不同线程可以分别观测到“(x,y) = (0,1)和(x,y) = (1,0)”)。
根本原因是
x和y的修改顺序对线程3、4的可见性不一定相同。
形象的类比:线程1、2并发执行,仿若x、y在赛跑何尝不是另一种意义的Data
race,一场“比赛”的结果是唯一的(即x、y要么有一个先“冲线”,即先变为1,要么二者同时“冲线”,即同时变为1,但其较为罕见);(x,y) == (0,1)和(x,y) == (1,0)
不可能同时成立双赢。
但acq_rel只对于其封装的单个变量有效,即:
- 其只能保证
x自身的所有acquire操作一定被排在release之后,即线程3/4去acquire对应的结果时,x的值如果已经被线程1修改,则其一定对线程3/4可见(y同理)。
- 但
x和y分属不同的变量,它们修改的顺序对不同线程的可见性不一定相同。
即,假设客观上x先于y被修改,但线程4依然可能看到y先于x被修改(可以通过lock-free
logging等体现之)。
若要求对两个(或多个变量)的修改顺序也全局可见,则有两种workaround:
- 改用
seq_cst,即所有原子变量的修改可见性在全局下也保持顺序一致;
- 将需要追踪的变量打包进单个
std::atomic,如改用struct {int x; int y;}s;怎么又在无名struct
Server:何以用之?
五种memory_order并无高低优劣之分——在保证安全性与程序语义正确的同时,最大化执行效率方为正道。
在 TiaoMeng 中,
对于计数器,若该计数器的逻辑对语义执行较重要(典型的如
failure_counter,当一个Connection的非致命无效请求达到一定次数后断开连接),则其对应的操作必须使用acquire/release/acq_rel(根据读/写类型而定),保证其兼具读/写语义:
而对于其它“闲杂”计数器1
2
3
4
5
6
7
8
9
10
11
12
13
14
15struct FailureTracker
{
// ...
[[nodiscard("record() returns whether count has exceeded max failure after pre self-increment.")]]
bool record()
{
return count.fetch_add(1, std::memory_order_acq_rel) + 1 >= max_failures;
}
void reset() { count.store(0, std::memory_order_release); }
[[nodiscard]] bool threshold_exceeded() const { return count.load(std::memory_order_acquire) >= max_failures; }
// ...
};说的就是你metrics,自增操作就可以大胆地用relaxed,因为只要程序执行结果正确即可(殊途同归之所有thread都join后的结果,即 destination 是确定的):
此处插入了一个有趣的设计哲学:1
2
3
4void add_bytes_sent(uint64_t bytes)
{
bytes_sent.fetch_add(bytes, std::memory_order_relaxed);
}
对于部分轻量级/一次性、但不希望因此引起各种奇怪UB的操作,acq_rel亦无妨。人话就是,自增可以
relaxed,reset必须release。
同时,最初在捞取数据时使用的是relaxed;后来决定改用可变策略——因涉及SIGUSR1实时触发metrics,若对数据准确性要求不高看个乐呵可用relaxed,但在debug等需要准确数据的情况下仍回退到acquire。1
2
3
4
5
6
7
8
9
10
11
12void reset()
{
start_time = std::chrono::steady_clock::now();
connections_accepted.store(0, std::memory_order_release);
connections_closed.store(0, std::memory_order_release);
// ...
}
[[nodiscard]] uint64_t get_bytes_sent(bool precise = false) const
{
return bytes_sent.load(precise ? std::memory_order_acquire : std::memory_order_relaxed);
}就读一次能有什么开销呢.sage
你又不是一秒65537次SIGUSR1