译者 | 卢鑫旺
审校 | 云昭
将Rust比作C++的小弟的话,相信大家都不会有异议。Rust借鉴了许多C++的设计思想。并发特性亦是如此。
Rust标准库的并发特性与C++ 11中的特性非常相似:线程、原子操作、锁和互斥量、条件变量等等。然而,在过去的几年中,随着C++ 17和C++ 20发布,C++已经获得了相当多新的与并发相关的特性,未来的版本还会有更多的可借鉴之处。
让我们花点时间来回顾一下C++的并发特性,讨论一下这些特性在Rust下会是什么样子的,以及要达到这个效果需要做些什么。
atomic_ref
P0019R8引入了std::atomic_ref到C++ 中。它是一种允许你将非原子对象用作原子对象的类型。例如,你可以创建一个atomic_ref<int>,它引用一个常规的int类型的变量,这时你可以使用与原子类型atomic<int>相同的功能,就跟它是atomic<int>一样。
在C++中,这需要一个复制大部分原子接口的全新类型,而等效的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<myownstuct>。另一方面,在Rust中,我们只有特定的原子类型:AtomicU32、AtomicBool、AtomicUsize等。
C++的原子类型支持任何大小的对象,无论平台是否支持。对于平台本机原子操作不支持的大小的对象,它会自动返回到基于锁的实现。Rust则只提供平台本机支持的类型。如果你正在用没有64位原子的平台进行编译,则AtomicU64不存在。
这有优点也有缺点。这意味着使用AtomicU64的Rust代码可能无法在某些平台上编译,但也意味着当某些类型默默地返回到一个非常不同的实现时,不会出现与性能相关的意外。这也意味着我们可以假设一个AtomicU64与内存中的u64完全相同,允许使用类似AtomicU64::from_mut的函数。在Rust中使用一个泛型原子类型atomic<T>来处理任何大小的类型可能会很棘手。没有专门化,我们无法使automic<LargeThing>包含Mutex,而不将其包含在automic<SmallThing>中。然而,我们可以做的是将互斥量存储在一个全局HashMap中,由内存地址索引。然后,automic<T>的大小可以与T相同,并在必要时使用此全局HashMap中的互斥量。这就是流行的atomic所做的事情。在Rust标准库中添加这样一个通用的范型automic<T>类型的建议需要讨论它是否应该在no_std程序中使用。常规哈希映射需要分配,这在no_std程序中是不可能的。固定大小的表可能适用于no_std程序,但由于各种原因可能不受欢迎。
Compare-exchange与填充
P0528R3更改了compare_exchange处理填充的方式。atomic<TypeWithPadding>上的比较交换操作也用于比较填充位,但结果证明这是一个坏主意。如今,填充位不再包括在比较中。
由于Rust目前只为整数提供原子类型,没有任何填充,因此此更改与Rust无关。然而,使用compare_exchange方法的atomic<T>方案需要讨论如何处理填充,并且可能需要从该方案中获取输入。
Compare-exchange内存排序
在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的一部分,解除了相同的限制。
Constexpr互斥量构造函数
C++的std::mutex有一个constexpr构造函数,这意味着它可以在编译时作为常量求值的一部分进行构造。然而,并非所有的实现都真正提供了这一点。例如,微软的std::mutex实现不包括constexpr构造函数。因此,依赖这一点对于可移植代码来说是个坏主意。
另外,有趣的是,C++的std:: condition_variable和std:: shared_mutex根本不提供constexpr构造函数。在Rust 1.0中,Rust的原始互斥不包括常量fn new。再加上Rust对静态初始化的严格要求,这使得在静态变量中使用互斥非常烦人。这在Rust 1.63.0中作为Rust lang/Rust#93740的一部分得到了解决,所有:
- Mutex:: new
- rBlock:: new
- Condvar:: new
现在都是常量函数。
Latches与barriers
P1135R6在C++20中引入了std::ltatch和std::barriers,这两种类型都允许等待多个线程到达某一点。latch基本上只是一个计数器,它由每个线程递减,并允许你等待它达到零。它只能使用一次。barrier是这种思想的更高级版本,可以重复使用,并接受计数器达到零时自动执行的“完成函数”。Rust从1.0开始就有了类似的barrier类型。它是受pthread(pthrea_Barrier_t)而不是C++的启发。Rust的(和pthread的)barrier不如C++中现在包含的灵活。它只有一个“递减和等待”操作(称为等待),并且缺少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上的waitoAddress()。可以用于这些操作的原子大小取决于操作系统及其版本。C++的counting_semaphore是一个模板,它以一个整数作为参数来指示我们希望能够计数到什么程度。例如,counting_semaphore<1000>可以计数到至少1000,因此将是16位或更大。binary_semaphore类型只是counting_Sema phore<1>的别名,在某些平台上可以是单个字节。在Rust中,我们可能还没有很快为这种泛型类型做好准备。Rust的泛型强制了某种一致性,这对我们可以将常量作为泛型参数进行处理带来了一些限制。
我们可以有单独的信号量32、信号量64等等,但这似乎有点过分了。拥有信号量<u32>和信号量<u64>甚至信号量<bool>都是可能的,但这是我们以前在标准库中没有做过的事情。我们的原子类型简单地是AtomicU32、AtomicU64等等。如上所述,对于我们的原子类型,我们只提供你正在编译的平台本机支持的类型。如果我们将同样的理念应用于信号量,它将不存在于没有futex或WaitoAddress功能的平台上,例如macOS。如果我们有不同大小的单独信号量类型,某些大小在(某些版本的)Linux和各种BSD上是不存在的。如果我们想在Rust中使用标准信号量类型,我们首先需要一些输入,说明我们是否确实需要不同大小的信号量,以及需要何种形式的灵活性和可移植性才能使它们有用。也许我们应该只使用一种始终可用的32位信号量类型(使用基于锁的回退),但任何此类建议都必须包括对用例和限制的详细解释。
原子等待和通知
P1135R6添加到C++20的其余新功能是原子等待和通知函数。
这些函数通过标准接口有效地直接公开Linux的futex()和Windows的waitoAddress()。
然而,无论操作系统支持什么,它们都可以在所有大小的原子上、所有平台上使用。Linux Futex(在FUTEX2之前)始终是32位的,但C++也允许atomic<uint64_t>:wait。
一种方法是使用类似于“停车场”的东西:有效地将内存地址映射到锁和队列的全局哈希映射。这意味着Linux上的32位等待操作可以使用非常快速的基于futex的实现,而其他大小的操作将使用非常不同的实现。如果我们遵循只提供本机支持的类型和函数的理念(就像我们对原子类型所做的那样),我们就不会提供这样的回退实现。这意味着我们在Linux上只有AtomicU32::wait(和AtomicI32::wait),而在Windows上,所有的原子类型都包括这个wait方法。在Rust中使用Atomic*::wait和Atomic*::notify需要讨论回退到全局表在Rust中是否合适。
jthread和stop_token
P0660R10将std::jthread和std::stop_token添加到了C ++20中。
如果我们暂时忽略stop_token,jthread基本上只是一个在销毁时自动获取join()方法的的常规std::thread。这避免了意外地分离线程并使其运行的时间比预期的长,这在常规线程中可能会发生。然而,它也引入了一个潜在的新陷阱:立即销毁jthread对象将立即加入线程,有效地消除了任何潜在的并行性。从Rust 1.63.0开始,提供了范围线程(Rust lang/Rust#93203)。与jthread一样,作用域线程也会自动加入。然而,它们的连接点是明确的,并且保证安全可靠。借用检查器甚至可以理解这一保证,允许你安全地借用作用域线程中的局部变量,只要这些变量超出作用域。除了自动加入之外,jthreads的一个主要特性是其stop_token和相应的stop_ source。可以在stop_source上调用request_stop(),使stop_ token上相应的stopUrequest()方法返回true。这可以很好地要求线程停止,并在加入之前在jthread的析构函数中自动完成。由线程的代码来实际检查令牌,并在设置时停止。到目前为止,它看起来几乎像一个普通的AtomicBool。不同的是stop_callback类型。这种类型允许用停止令牌注册回调函数,即“停止函数”。使用相应的停止源请求停止将执行此功能。实际上,线程可以使用它来让其他线程知道如何停止或取消其工作。
在Rust中,我们可以很容易地将类似atomicboolean的功能添加到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来公开功能。
原子shared_ptr
P0718R2为C++20添加了atomic<shared_ptr>和atomic<weak_ptr>的专门化。
引用计数指针(C++中的shared_ptr,Rust中的Arc)通常用于并发无锁数据结构。通过正确处理引用计数,原子<shared_ptr>专门化使正确执行此操作更加容易。
在Rust中,我们可以添加等效的AtomicArc<T>和AtomicWeak<T>类型。(虽然AtomicArc听起来有点奇怪,但考虑到Arc的A已经代表“原子”了。)
然而,C++的shared_ptr<T>是可为空的,而在Rust中,它需要一个选项<Arc<T>。目前还不清楚AtomicArc<T>是否应该为空,或者我们是否也应该有一个AtomicOptionArc<T>。
流行的arc-swap已经在Rust中提供了所有这些变体,但据我所知,目前还没有任何类似于标准库的建议。
synchronized_value
尽管P0290R2没有被接受,但提出了一种称为synchronized_value<T>的类型,它将互斥锁与数据类型T组合在一起。尽管它当时没有被C++接受,但这是一个有趣的建议,因为synchronize_value<T>与Rust中的Mutex<T>几乎完全相同。
在C++中,std::mutex不包含它保护的数据,甚至根本不知道它保护的是什么。这意味着,需要由用户来记住哪些数据受保护以及由哪个互斥锁保护,并确保每次访问“受保护”数据时锁定正确的互斥锁。Rust的Mutex设计,使用了一个类似于(可变的)T引用的MutexGuard,这使得安全性更高,同时在只需要一个互斥锁而不需要任何数据的情况下,仍然允许使用Mutex<()>。synchronized_value的提议试图将此模式添加到C++中,但是使用闭包而不是互斥锁,因为C++不跟踪生命期。
结语
在笔者看来,C++可以继续成为Rust的灵感来源,尽管“直接复制粘贴”的想法并不值得提倡,但好的思想还是要学习和继承的。正如我们看到的Mutex,作用域线程,Atomic*::from_mut等,在Rust中提供相同功能的同时,事情往往会变得非常不同。
当然,提供与C++完全相同的功能不应该是主要目标。目标应该是准确地提供Rust生态系统从语言和标准库中需要的东西,这可能与C++用户从他们的语言中需要的东西不同。如果你有来自Rust标准库的并发需求,而目前还没有满足,欢迎把它留在评论区,不管它是否已经用另一种语言解决了。
原文链接:
https://blog.m-ou.se/rust-cpp-concurrency/
译者介绍
卢鑫旺,51CTO社区编辑,编程语言爱好者,对数据库,架构,云原生有浓厚兴趣,目前就职某跨境电商出海营销公司,担任后端开发工作。