C++

C++ 的心智负担 -- Integral promotion

Posted by w@hidva.com on March 28, 2022

我个人习惯于将自己对 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++ 了嘛!