摘要:同时接触过 Rust 和 C++ 的程序员们可能会有一种感觉——二者的并发特性很相似。不过 Rust 本就借鉴了许多 C++ 的设计思想,所以也不足为奇。为此,本文将具体分析二者的在并发特性上的区别,看看相同的功能下,Rust 和 C++ 会分别采取什么形式。
原文链接:https://blog.m-ou.se/rust-cpp-concurrency/
声明:本文为 CSDN 翻译,未经授权,禁止转载。
Rust标准库中包含的并发特性与C++ 11非常相似:线程、原子、互斥锁、条件变量等。然而,在过去的几年里,C++ 17和C++ 20发布了许多与并发相关的新特性,而且在未来的几个版本中还有更多提案。
在本文中,我们来回顾一下C++的并发特性,并讨论一下相同的功能在Rust中是什么样子,以及我们应该如何实现。
P0019R8在C++中引入了std::atomic_ref。这是一种允许你将非原子对象当作原子对象的类型。例如,你可以创建一个atomic_ref<int>,指向常规的int,这样就可以把它当成atomic<int>使用了。
C++的这个方案需要定义一个新类型,其接口大部分与atomic重复,但用Rust实现相同的功能只需要一个只有一行代码的函数:Atomic*::from_mut。例如,你可以利用这个函数将&mut u32转换为&AtomicU32,在Rust中这就相当于一个别名。
C++中的atomic_ref类型有一定的安全要求,你需要手动支持。只要使用atomic_ref访问对象,则该对象的所有访问都必须通过atomic_ref。在有atomic_ref的情况下,直接访问对象会引发未定义的行为。
然而,在Rust中,这些细节皆由借用检查器处理。编译器明白,如果以可变的形式借用u32,则在借用结束之前,不允许任何东西直接访问u32。函数from_mut中的&mut u32的生命周期会被作为&AtomicU32的一部分保留下来。你可以根据需要随意复制AtomicU32,原始借用仅在该引用的所有副本都消失后才结束。
目前函数from_mut依然处于不稳定版本,也许是时候把它放到稳定版中了。
在C++中,std::atomic是通用的:你可以使用atomic<int>,也可以使用atomic<MyOwnStruct>。另一方面,Rust中只有特定的原子类型:AtomicU32、AtomicBool、AtomicUsize 等。
C++的原子类型支持任意大小的对象,无论平台支持什么。如果平台的原生原子操作无法支持对象的大小,它会自动退而采用基于锁的实现。另一方面,Rust只提供平台原生支持的类型。如果平台不支持64位的原子,则不存在AtomicU64。
当然这有优点,也有缺点。这意味着,使用AtomicU64的Rust代码可能无法在某些平台上编译,但这也意味着不会由于某些类型悄悄地采用一个非常不同的实现而导致性能问题。此外,这也意味着,我们可以假设AtomicU64与u64在内存中的表示完全相同,因此可以使用AtomicU64::from_mut之类的函数。
在Rust中,建立一个可支持任何大小的通用Atomic<T>的难度比较大。如果没有特化,那么如果Atomic<SmallThing>中不包含互斥锁,Atomic<LargeThing>就无法包含互斥锁。然而,我们可以将互斥锁存储在一个全局HashMap中,由内存地址索引。如此一来,Atomic<T>的大小就与T相同,而且还可以在必要时使用全局HashMap中的互斥锁。
如今流行的atomic crate选择的就是这种方法。
在Rust标准库中添加这种通用Atomic<T>的提案需要讨论是否应该在no_std程序中它。常规的HashMap需要分配内存,这在no_std程序中是不可能的。固定大小的表可用于no_std程序,但由于各种原因可能并不受欢迎。
P0528R3改变了compare_exchange处理填充的方式。atomic<TypeWithPadding>的比较/交换操作也会对填充位进行处理,但结果证明这种方法并不理想。如今,比较操作中不再包含填充位。
由于Rust当前仅提供整数的原子类型,没有任何填充,因此这个变化与Rust无关。
但是,带有compare_exchange方法的Atomic<T>提案需要讨论如何处理填充,并且应该听取这个提案的意见。
在C++ 11中,compare_exchange函数要求比较成功的内存顺序至少不能弱于失败的内存顺序。不接受compare_exchange(…, …, memory_order_release, memory_order_acquire)。Rust的compare_exchange函数完整地复制了这个要求。
P0418R2认为应该取消这个限制,而这个提案将在C++ 17中推出。
另一方面,Rust 1.64也取消了相同的限制,请参见rust-lang/rust#98383。
C++的std::mutex有一个constexpr构造函数,这意味着它可以在编译时作为常量评估的一部分来构造。然而,并不是所有的实现都提供了这一点。例如,微软的std::mutex实现就不包括constexpr构造函数。要想代码具备可移植性,就不能依赖这个构造函数。
此外,有趣的是,C++的std::condition_variable和std::shared_mutex根本没有提供constexpr构造函数。
Rust 1.0中的Mutex不包含const fn new。再加上Rust对静态初始化的严格要求,因此很难在静态变量中使用Mutex。
这个问题已在Rust 1.63.0(rust-lang/rust#93740)中得到了解决:所有Mutex::new、RwLock::new和Condvar::new都是const函数。
P1135R6在C++ 20中引入了std::latch和std::barrier。两者都允许等待多个线程到达某个点。其实latch就是一个计数器,根据每个线程递减,直到变成零。latch只能使用一次。barrier是一个更高级的版本,可以重用,并接受一个“完成函数”,当计数器达到零时自动执行。
Rust从1.0开始有了类似的Barrier类型。但它的灵感来自pthread (pthread_barrier_t)。
Rust(以及pthread)的barrier不如C++灵活。它只有一个“递减和等待”操作(wait),没有C++的std::barrier自带的“只有等待”、“只有递减”以及“递减并丢弃”等功能。
另一方面,与C++不同,Rust(以及pthread)的“递减和等待”操作会指定一个线程作为组长,它可以代替“完成函数”(而且可能更灵活)。
Rust缺少的操作其实可以随意添加。我们只需要提出这些新方法的名称。
P1135R6还在C++ 20中引入了信号量:std::counting_semaphore和std::binary_semaphore。
Rust没有通用的信号量类型,尽管它通过thread::park和unpark为每个线程分配了二进制信号量。
我们可以使用Mutex<u32>和Condvar手动构建信号量,但大多数操作系统允许使用AtomicU32实现更高效和更小的信号量。例如,Linux上的futex()和Windows上的WaitOnAddress()。具体的实现取决于操作系统及其版本,以及哪些原子大小可用于这些操作。
C++的counting_semaphore是一个模板,它接受一个整数作为参数,用于传达我们希望统计的数量。例如,counting_semaphore<1000>的计数至少为1000,因此可以是16位或更大。binary_semaphore类型只是counting_semaphore<1>的别名,在某些平台上可以是单字节。
Rust还有这种通用的类型。Rust的泛型强制实现了某种程度的一致性,因此将常量作为泛型参数时也会受到一定的限制。
我们有单独的Semaphore32、Semaphore64等等,但这似乎有点矫枉过正。Semaphore<u32>、Semaphore<u64>,甚至是Semaphore<bool>固然没有什么问题,但以前的标准库中并没有这些。我们的原子类型就是AtomicU32、AtomicU64等等。
如上所述,对于原子类型,我们仅提供编译平台原生支持的类型。如果我们将相同的理念应用于信号量,它就不会存在于没有futex或WaitOnAddress函数的平台上,比如macOS。如果我们根据大小建立不同的信号量类型,那么某些大小就不会出现在(某些版本的)Linux和各种BSD中了。
如果我们希望Rust建立标准的信号量类型,首先需要了解我们是否需要不同大小的信号量,以及灵活性和可移植性需要达到什么程度。也许我们只需要一个32位的信号量(使用基于锁的替代方案),但任何此类的提议都必须包含用例和限制的详细说明。
P1135R6添加到C++ 20的另一个新特性是原子wait和notify函数。
这些函数通过标准接口,公开了Linux上的futex()和Windows上的WaitOnAddress()。
然而,无论操作系统支持什么,所有平台上的所有大小的原子都可以使用这两个函数。Linux的futex(在FUTEX2之前)是32位的,但C++允许使用atomic<uint64_t>::wait。
这其实类似于“停车场”:将内存地址映射到锁和队列的全局HashMap。这意味着,Linux上的32位等待操作可以使用基于futex的实现,而其他大小使用的实现则完全不同。
如果我们遵循只提供原生支持的类型和函数的理念,就不会提供这样的实现了。这意味着,Linux上只有AtomicU32::wait(和AtomicI32::wait),而Windows上所有原子类型都包含wait方法。
在Rust中,有关Atomic*::wait和Atomic*::notify的提案需要讨论Rust是否需要退而采用全局表。
P0660R10向C++ 20引入了std::jthread和std::stop_token。
暂时抛开stop_token不谈,其实jthread只是一个常规的std::thread,它会在销毁时自动被join() 。这可以避免线程意外脱离,并长期运行。但是,它也引入了一个潜在的新陷阱:即时销毁jthread对象会立即join线程,导致并行消失。
Rust从版本1.63.0开始,加入了有作用域的线程(rust-lang/rust#93203)。就像jthread一样,作用域线程会自动join。但是,它们之前的连接点很明确,并且是可以依赖的安全保证。此外,借用检查器明白这种安全保证,允许你安全地借用作用域线程中的局部变量,只要这些变量只在作用域中即可。
除了自动join之外,jthreads还有一个主特点,就是stop_token以及对应的stop_source。如果在stop_source上调用request_stop(),则stop_token上相应的stop_requested()方法会返回true。我们可以利用这个方法来请求线程停止,而这一步会在join之前在jthread的析构函数中自动完成。而检查并停止token的处理则由线程的代码完成。
到目前为止,它看起来就像一个普通的AtomicBool。
其实,二者最大的区别在于stop_callback类型。该类型允许给停止令牌注册一个回调函数,也就是一个“停止函数”。使用相应的stop_source请求停止就可以执行该函数。实际上,我们可以利用这个函数来停止或取消线程。
在Rust中,我们可以将类似AtomicBool的功能添加到thread::scope的Scope对象中。只需一个简单的函数is_finished(&self) -> bool或stop_requested(&self) -> bool即可,用于指示主范围函数是否已完成。然后就可以结合request_stop(&self)方法从任何地方发送请求。
stop_callback的功能更复杂,Rust的相关提案需要详细讨论其接口、用例和限制。
P0020R6在C++ 20中添加了对原子浮点加法和减法的支持。
在Rust中添加AtomicF32或AtomicF64也很容易,但似乎唯一支持原生原子浮点运算的平台都是一些GPU,但Rust还不支持。
将这些类型添加到Rust的提议必须提出一些引人注目的用例。
目前,我们还无法在Rust或C++中有效地实现遵守内存模型所有规则的序列锁。
P1478R7建议在C++中添加atomic_load_per_byte_memcpy和atomic_store_per_byte_memcpy来解决这个问题。
对于Rust,我写了一个提案,希望通过AtomicPerByte<T>类型来公开这个功能:RFC 3301。
P0718R2在C++ 20中添加了atomic<shared_ptr>和atomic<weak_ptr>。
引用计数指针(C++中的shared_ptr,Rust中的Arc)多用于并发无锁数据结构。atomic<shared_ptr>可以正确处理引用计数,从而降低了正确执行此操作的难度。
在Rust中,我们可以添加等效的AtomicArc<T>和AtomicWeak<T>类型。
然而,C++的shared_ptr<T>可以为null,而在Rust中则需要Option<Arc<T>>。目前还不清楚AtomicArc<T>是否可以为null,或者我们是否应该提供AtomicOptionArc<T>。
流行的arc-swap crate已经在Rust提供了一些变体,但是,据我所知,还没有任何提议将类似的东西添加到标准库。
虽然P0290R2提出了一种名叫synchronized_value<T>的类型,它结合了互斥锁和T,但最终没有被接受。
虽然没有被C++接受,但这是一个有趣的提议,因为synchronized_value<T>几乎就是Rust中的Mutex<T>。
在C++中,std::mutex不包含它保护的数据,甚至根本不知道它保护的是什么。这意味着,用户必须担负起责任,记住哪些数据受保护以及由哪个互斥锁保护,并确保每次访问“受保护”数据时都锁定正确的互斥锁。
在Rust Mutex<T>的设计中,MutexGuard的行为类似于对T的(可变)引用,可以提供更高的安全性,同时在只需要互斥锁的情况下,也可以使用Mutex<()>。synchronized_value<T>的提议是,将此模式添加到C++,但使用闭包而不是互斥锁,因为C++不跟踪生命周期。
在我看来,Rust可以继续学习C++的经验,但我们不能直接复制粘贴C++的想法。正如我们在Mutex<T>、作用域线程、Atomic*::from_mut中看到的那样,在提供相同功能时,Rust可以采用完全不同的形式。
我们的目标不是提供与C++完全相同的功能,而应该是提供Rust生态系统以及标准库需要的东西,而这可能与C++用户的需求完全不同。