软件中的常见操作是复制内存块。在C/C++中,我们经常调用函数memcpy来达到这个目的。
但是,如果在复制数据时另一个线程正在修改源或目标,会发生什么情况?结果从根本上是不可预测的,并且几乎肯定是编程错误。
既然复制函数是错误的,为什么要以这种方式编写它呢?假设您正在用 C++ 实现 JavaScript 引擎,例如 Google v8。在 JavaScript 中,我们有 SharedArrayBuffer 实例,可以从不同的线程修改和复制它们。作为开发 JavaScript 引擎的工程师,您无法始终阻止用户编写有错误的代码。
无论如何,您都会遇到数据竞争:两个或多个线程同时访问同一内存位置,其中至少有一个访问是写入操作,而没有同步机制来确保这些操作按特定顺序发生。
会发生什么? C++ 标准规定数据竞争会导致未定义的行为。实际上,C++ 语言不会告诉您会发生什么。可能会发生崩溃。当然,JavaScript 工程师不愿意看到崩溃。
重要的是,“未定义的行为”也不会告诉您一定存在错误。实际上,它告诉您作为程序员,您有额外的责任来确保代码是安全的。编程语言本身不提供任何保证。
为什么像 C 和 C++ 这样的语言会留下未定义的行为?
一个很好的比喻是一个拥有许多子组件的组织,可以随时添加新的子组件。想象一下行星的星际联邦。星际联邦可以指定明确定义的总体法律,但仍会存在特定于您居住的星球的剩余极端情况。
这就是 C 和 C++ 的精神:这些编程语言可以针对非常广泛的平台。对于其中一些平台来说,数据竞争不会产生任何后果……而对于其他平台来说,数据竞争可能会带来很大的问题或只是速度缓慢。此外,通过不指定行为,它允许编译器设计者进行一些选择。因此,编程语言将其留给您来检查。
考虑一个冲突的内存复制,例如,您从数组 A 复制到数组 B,而另一个线程从数组 B 复制到数组 A。在大多数平台下,这不会导致崩溃或任何特别危险的情况。在最坏的情况下,您的数组中可能会出现垃圾数据。
但如果您使用自动清理工具,您仍然可能会收到有关数据争用的警告,即使它无关紧要。您可以通过告诉工具您已检查副本是否安全来消除警告。
相反,您可以滚动自己的“安全”内存副本,以原子方式逐字节加载内容(例如)。 C++20 中可能的解决方案如下所示:
void safe_memcpy ( char * dest , const char * src , size_t count ) { for ( size_t i = 0 ; i <计数; + + i ) { 字符输入= std :: atomic_ref < const char > ( src [ i ] ) 。加载( std :: memory_order_relaxed ) ; std :: atomic_ref < char > ( dest [ i ] ) 。存储(输入, std :: memory_order_relaxed ) ; } }
我们现在已经废除了任何类型的未定义行为。代码应该是完全“安全”的,不再有数据竞争。
那么为什么不总是使用这种安全的方法呢?
因为它可能比传统内存复制慢 40 倍。
这成为一个工程问题。有时候,性能确实并不重要。
在编程中,几乎没有免费的午餐。你通常会做出选择:追求高绩效但承担更多责任,或者牺牲绩效以减少后顾之忧。
原文: https://lemire.me/blog/2025/02/07/thread-safe-memory-copy/