对比C++并发库,Rust简直不要太像!

2022年8月23日 297点热度 0人点赞 0条评论
图片
译者 | 卢鑫旺
审校 | 云昭

将Rust比作C++的小弟的话,相信大家都不会有异议。Rust借鉴了许多C++的设计思想。并发特性亦是如此。

Rust标准库的并发特性与C++ 11中的特性非常相似:线程、原子操作、锁和互斥量、条件变量等等。然而,在过去的几年中,随着C++ 17和C++ 20发布,C++已经获得了相当多新的与并发相关的特性,未来的版本还会有更多的可借鉴之处。

让我们花点时间来回顾一下C++的并发特性,讨论一下这些特性在Rust下会是什么样子的,以及要达到这个效果需要做些什么。


  PART  01  

 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函数目前不太稳定,但也许是时候稳定它了。

  PART  02  

 泛型原子类型 

在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程序,但由于各种原因可能不受欢迎。

  PART  03  

 Compare-exchange与填充 

P0528R3更改了compare_exchange处理填充的方式。atomic<TypeWithPadding>上的比较交换操作也用于比较填充位,但结果证明这是一个坏主意。如今,填充位不再包括在比较中。
由于Rust目前只为整数提供原子类型,没有任何填充,因此此更改与Rust无关。

然而,使用compare_exchange方法的atomic<T>方案需要讨论如何处理填充,并且可能需要从该方案中获取输入。

  PART  04  

 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的一部分,解除了相同的限制。
  PART  05  

 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

现在都是常量函数。
  PART  06  

 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版本上缺失的操作可以在任何时候轻松添加。我们所需要的只是这些新方法的名称的一个好建议。
  PART  07  

 信号量 

同样的,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位信号量类型(使用基于锁的回退),但任何此类建议都必须包括对用例和限制的详细解释。
  PART  08  

 原子等待和通知 

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中是否合适。
  PART  09  

 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的等价功能都可能需要详细的提议来讨论它的接口、用例和限制。

  PART  10  

 原子浮点数 

P0020R6在C++ 20中增加了对原子浮点加法和减法的支持。

在Rust中添加AtomicF32或AtomicF64也很容易,但吊诡的是,似乎目前原生支持原子浮点运算的平台往往是GPU厂商,而Rust现在好像并没有提供对这些平台的支持。

关于向Rust添加这些类型方面,强烈建议提供一些实用的用例。
  PART  11  

 字节原子内存 

目前,在Rust或C++中不可能有效地实现遵循内存模型所有规则的序列锁。
P1478R7建议在未来的C++版本中添加atomic_load_per_byte_memcpy和
atomic_store_per_byte_memcpy来解决这个问题。
对于Rust,这里给出一个想法,就是可以通过AtomicPerByte<T>类型:RFC 3301来公开功能。
  PART  12  

 原子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中提供了所有这些变体,但据我所知,目前还没有任何类似于标准库的建议。
  PART  13  

 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++不跟踪生命期。
  PART  14  

 结语 

在笔者看来,C++可以继续成为Rust的灵感来源,尽管“直接复制粘贴”的想法并不值得提倡,但好的思想还是要学习和继承的。正如我们看到的Mutex,作用域线程,Atomic*::from_mut等,在Rust中提供相同功能的同时,事情往往会变得非常不同。
当然,提供与C++完全相同的功能不应该是主要目标。目标应该是准确地提供Rust生态系统从语言和标准库中需要的东西,这可能与C++用户从他们的语言中需要的东西不同。

如果你有来自Rust标准库的并发需求,而目前还没有满足,欢迎把它留在评论区,不管它是否已经用另一种语言解决了。

原文链接:

https://blog.m-ou.se/rust-cpp-concurrency/



译者介绍

卢鑫旺,51CTO社区编辑,编程语言爱好者,对数据库,架构,云原生有浓厚兴趣,目前就职某跨境电商出海营销公司,担任后端开发工作。



直播预告

8月25日20:00,中关村科金AI安全攻防实验室总监冯月将做客【T·Talk】直播间,分享中关村科金多模态生物核验与防伪算法融合体系的技术原理与技术实践。


无论你是算法行业从业人员,还是热衷于生物防伪技术的开发者,相信你都能从本次分享中收获一些别样的技术经验与解决方案。


点击视频号卡片,立即预约直播

51CTO技术栈

将在08月25日 20:00 直播

【T-TALK】第18期:筑牢生物防伪安全壁垒:下一代生物核身技术解析

视频号

图片

微信上线消费者保护功能、TikTok否认内置浏览器监视用户、小米向3142人奖励约9.42亿元人民币 | T资讯

苹果设备被曝“内核”漏洞,细节成迷

7款好用的前端框架,试试就知道


点击此处“阅读全文”查看精彩内容
图片
80630对比C++并发库,Rust简直不要太像!

这个人很懒,什么都没留下

文章评论