我个人习惯于将自己对 C++ 的经验分为三类: 纯粹的知识点, 喜好的代码风格, 以及心智负担; 在心智负担这一类中又记录了心智负担能否被编译器检测出来, 以及对应的编译选项及工具; 从我目前的笔记大小来看, C++ 确实是一个心智负担包袱很大的语言了== 这里介绍一下我记录的其中一个心智负担.
在我将 tokio 完整地移植到 C++ 作为 C++20 coroutine 运行时之后, 测试时在 local run queue 附近遇到一个很诡异的 coredump. tokio 中用作 local run queue 的数据结构参考了 golang scheduler 实现, a fixed size single-producer, multi-consumer queue; a circular buffer, using an array to store values. Atomic integers are used to track the head and tail positions. coredump 附近逻辑如下所示:
// 调用该函数将一个处于 "可运行状态" 的 job 扔到 local runq 中, 等待有机会运行.
// 如果此时 local runq 已满, 则扔到 global_q 指向的 global run queue 中.
void push_back(task::Notified<S> job, const Inject<S>& global_q) {
auto& self = *this;
u16 tailidx = 0;
while (true) {
u32 head = self.inner->load_head();
auto [steal, real] = unpack(head);
u16 tail = unsync_load(self.inner->tail);
if ((tail - steal) < LOCAL_QUEUE_CAPACITY) {
// (tail - steal) 为 local run queue 中已有 job 的个数,
// 小于 LOCAL_QUEUE_CAPACITY 意味着 local runq 中仍有空闲空间,
tailidx = tail;
break;
}
// 此时意味着 local runq 已满,
if (steal != real) {
// 此时意味着另有一个 worker 正在尝试从当前 worker local runq 中偷取任务.
// 意味着当前 worker local runq 很快便有空闲空间, 先将 job 扔到 global runq 中.
global_q.push(std::move(job));
return;
}
// 此时表明 local runq 已满, 并且没有其他 worker 尝试偷取本 local runq 中任务,
// 则将本 local runq 一半的 job 扔到 global runq 中.
// push_overflow() 实现如下所示:
if (self.push_overflow(std::move(job), real, tail, global_q)) {
return;
}
}
}
bool push_overflow(task::Notified<S>&& job, u16 head, u16 tail, const Inject<S>& global_q) {
auto& self = *this;
// coredump 显示这里 check failed!
STD_RT_CHECK((tail - head) == (LOCAL_QUEUE_CAPACITY), "queue is not full, tail=%hu head=%hu", tail, head);
}
很诡异, 根据 push_back() 逻辑可知, 当流程走到 push_overflow() 时, 如下两个条件成立:
(tail - steal) >= LOCAL_QUEUE_CAPACITY
, 考虑到另有其他逻辑限定(tail - steal) <= LOCAL_QUEUE_CAPACITY
, 即(tail - steal) == LOCAL_QUEUE_CAPACITY
.steal == real
即 tail - real == LOCAL_QUEUE_CAPACITY
, 但 coredump 明明白白显示 tail=0 head=65361
, check failed. 提炼一下最小可复现问题的代码:
#include <stdio.h>
#include <stdint.h>
constexpr uint64_t LOCAL_QUEUE_CAPACITY = 256;
int main() {
uint16_t real = 65361;
uint16_t tail = 0;
uint16_t diff = tail - real;
printf("%hu\n", diff); // print 175
if ((tail - real) < LOCAL_QUEUE_CAPACITY) {
puts("hello");
} else {
puts("world"); // 执行流走到了这里!!!
}
return 0;
}
然后依稀记得之前调研 C++表达式求值顺序的一个小问题, 总是使用 int 及以上来作为整数类型 时遇到的 C++ 表达式求值时一个纯粹的知识点: Integral promotion, 大意是指 C++ 在表达式计算前, 会将表达式中整型操作数类型提升一下再运算… 所以如果我们将上面代码中的 (tail - real) < LOCAL_QUEUE_CAPACITY
换成 uint16_t(tail - real) < LOCAL_QUEUE_CAPACITY
便可得到符合预期的结果… 而且很不幸, -Wall, -Wextra
并不能检测出这种情况, 这又是一个扎扎实实的心智负担 :-(
回到 Rust 中, 则没有这个问题, 下面这个例子, Debug 时会 panicked at ‘attempt to subtract with overflow’; Release 时会得到符合预期的结果, 并没有 Integral promotion 这个其他的步骤.
const LOCAL_QUEUE_CAPACITY: u64 = 256;
#[allow(arithmetic_overflow)]
fn main() {
let real: u16 = 65361;
let tail: u16 = 0;
let diff: u16 = tail - real;
println!("diff: {}", diff);
if ((tail - real) as u64) < LOCAL_QUEUE_CAPACITY {
println!("hello");
} else {
println!("world");
}
}
所以我始终认为, 新项目没有必要再使用 C++ 了嘛!