4.1 C++11:并发支持
C++11 必须支持并发。这既是显而易见的,也是所有主要用户和平台供应商的共同需求。C++ 一直在大多数软件工业的基础中被重度使用,而在二十一世纪的头十年,并发性变得很普遍。利用好硬件并发至关重要。和 C 一样,C++ 当然一直支持各种形式的并发,但这种支持那时没有标准化,并且一般都很底层。机器架构正在使用越来越精巧的内存架构,编译器编写者也在应用越来越激进的优 化技术,这让底层软件编写者的工作极为困难。机器架构师和优化器编写者之间亟需一个协定。只有有了明确的内存模型,基础库的编写者才能有一个稳定的基础和 一定程度的可移植性。
并发方面的工作从 EWG 中分离出来,成为由 Hans-J. Boehm(惠普,后加入谷歌)领导的专家成员组成的并发组。它有三项职责:
- §4.1.1:内存模型
- §4.1.2:线程和锁
- §4.1.3:期值
此外,并行算法(§8.5)、网络(§8.8.1)和协程(§9.3.2)是单独分组处理的,并且(正如预期)还没法用于 C++11。
4.1.1 内存模型
最紧迫的问题之一,是在一个有着多核、缓存、推测执行、指令乱序等的世界里精确地规定访问内存的规则。来自 IBM 的 Paul McKenney 在内存保证方面的课题上非常活跃。来自剑桥大学的 Mark Batty 的研究 [Batty et al. 2013, 2012, 2010, 2011] 帮助我们将这一课题形式化,见 P. McKenney、M. Batty、C. Nelson、H. Boehm、A. Williams、S. Owens、S. Sarkar、P. Sewell、T. Weber、M. Wong、L. Crowl 和 B. Kosnik 合作的论文 [McKenney et al. 2010]。它是 C++11 的一个庞大而至关重要的部分。
在 C11 中,C 采用了 C++ 的内存模型。然而,就在 C 标准付诸表决前的最后一刻,C 委员会引入了不兼容的写法,而此时 C++11 标准修改的最后一次机会已经过去。这成了 C 和 C++ 实现者和用户的痛苦。
内存模型很大程度上是由 Linux 和 Windows 内核的需求驱动的。目前它不只是用于内核,而且得到了更加广泛的使用。内存模型被广泛低估了,因为大多数程序员都看不到它。从一阶近似来看,它只是让代码按照任何人都会期望的方式正常工作而已。
最开始,我想大多数委员都小瞧了这个问题。我们知道 Java 有一个很好的内存模型 [Pugh 2004],并曾希望采用它。令我感到好笑的是,来自英特尔和 IBM 的代表坚定地否决了这一想法,他们指出,如果在 C++ 中采用 Java 的内存模型,那么我们将使所有 Java 虚拟机的速度减慢至少两倍。因此,为了保持 Java 的性能,我们不得不为 C++ 采用一个复杂得多的模型。可以想见而且讽刺的是,C++ 此后因为有一个比 Java 更复杂的内存模型而受到批评。
基本上,C++11 模型基于先行发生(happens-before)关系 [Lamport 1978],并且既支持宽松的内存模型,也支持顺序一致 [Lamport 1979] 的模型。在这些之上,C++11 还提供了对原子类型和无锁编程的支持,并且与之集成。这些细节远远超出了本文的范围(例如,参见 [Williams 2018])。
不出所料,并发组的内存模型讨论有时变得有点激烈。这关系到硬件制造商和编译器供应商的重大利益。最困难的决定之一是同时接受英特尔的 x86 原语(某种全存储顺序,Total Store Order(TSO)模型 [TSO Wikipedia 2020] 加上一些原子操作)和 IBM 的 PowerPC 原语(弱一致性加上内存屏障)用于最底层的同步。从逻辑上讲,只需要一套原语,但 Paul McKenney 让我相信,对于 IBM,有太多深藏在复杂算法中的代码使用了屏障,他们不可能采用类似英特尔的模型。有一天,我真的在一个大房间的两个角落之间做了穿梭外交。最后,我提 出必须支持这两种方式,这就是 C++11 采用的方式。当后来人们发现内存屏障和原子操作可以一起使用,创造出比单单使用其中之一更好的解决方案时,我和其他人都感到非常高兴。
稍后,我们增加了对基于数据依赖关系的一致性支持,通过属性(§4.2.10)在源代码中表示,比如 [[carries_dependency]]
。
C++11 引入了 atomic
类型,上面的简单操作都是原子的:
atomic<int> x;
void increment()
{
x++; // 不是 x = x + 1
}
显然,这些都是广泛有用的。例如,使用原子类型使出名棘手的双重检查锁定优化变得极为简单:
mutex mutex_x;
atomic<bool> init_x; // 初始为 false
int x;
if (!init_x) {
lock_guard<mutex> lck(mutex_x);
if (!init_x) x = 42;
init_x = true ;
} // 在此隐式释放 mutex_x(RAII)
// ... 使用 x ...
双重检查锁定的要点是使用相对开销低的 atomic
保护开销大得多的 mutex
的使用。
lock_guard
是一种 RAII 类型(§2.2.1),它确保会解锁它所控制的 mutex
。
Hans-J. Boehm 将原子类型描述为令人惊讶地流行
,但我不能说我感到惊讶。我没 Hans 那么专业,对简化更为欣赏。C++11 还引入了用于无锁编程的关键运算,例如比较和交换:
template<typename T>
class stack {
std::atomic<node<T>*> head;
public:
void push(const T& data)
{
node<T>* new_node = new node<T>(data);
new_node->next = head.load(std::memory_order_relaxed);
while(!head.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release, std::memory_order_relaxed)) ;
}
// ...
};
即使有了 C++11 的支持,我仍然认为无锁编程是专家级的工作。
4.1.2 线程和锁
在内存模型之上,我们还提供了线程和锁
(threads and locks)的并发模型。我认为线程和锁级别的并发是应用程序使用并发最差的模型,但是对于 C++ 这样的语言来说,它仍然必不可少。不管它还是别的什么,C++ 一直是一种能够与操作系统直接交互的系统编程语言,可用于内核代码和设备驱动程序。因此,它必须支持系统最底层支持的东西。在此基础上,我们可以建立各种 更适合特定应用的并发模型。就我个人而言,我特别喜欢基于消息的系统,因为它们可以消除数据竞争,而数据竞争可能产生极为隐晦的并发错误。
C++ 对线程和锁级别编程的支持是 POSIX 和 Windows 所提供的线程和锁的类型安全变体。在 [Stroustrup 2013] 有所描述,在 Anthony Williams 的书 [Williams 2012, 2018] 中有更为深入的探讨:
thread
——系统的执行线程,支持join()
和detach()
mutex
——系统的互斥锁,支持lock()
、unlock()
和保证unlock()
的 RAII 方式condition_variable
——系统中线程间进行事件通信的条件变量thread_local
——线程本地存储
与 C 版本相比,类型安全使代码更简洁,例如,不再有 void**
和宏。考虑一个简单的例子,让一个函数在不同的线程上执行并返回结果:
class F { // 传统函数对象
public:
F(const vector<double>& vv, double* p) : v{vv}, res{p} { }
void operator()(); // 将结果放入 *res
private:
const vector<double>& v; // 输入源
double* res; // 输出目标
};
double f(const vector<double>& v); // 传统函数
void g(const vector<double>& v, double* res); // 将结果放入 *res
int comp(vector<double>& vec1, vector<double>& vec2, vector<double>& vec3)
{
double res1;
double res2;
double res3;
// ...
thread t1 {F{vec1,&res1}}; // 函数对象
thread t2 {[&](){res2=f(vec2);}}; // lambda 表达式
thread t3 {g,ref(vec3),&res3}; // 普通函数
t1.join();
t2.join();
t3.join();
cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}
类型安全库支持的设计非常依赖变参模板(§4.3.2)。例如,std::thread
的构造函数就是变参模板。它可以区分不同的可执行的第一个参数,并检查它们后面是否跟有正确数量正确类型的参数。
类似地,lambda 表达式(§4.3.1)使 <thread>
库的许多使用变得更加简单。例如,t2
的参数是访问周围局部作用域的一段代码(lambda 表达式)。
在发布标准的同时,让新特性在标准库中被接受和使用是很困难的。有人提出这样做过于激进,可能会导致长期问题。引入新的语言特性并同时使用它们无疑是有风险的,但它通过以下方式大大增加了标准的质量:
- 给用户一个更好的标准库
- 给用户一个很好的使用语言特性的例子
- 省去了用户实现底层功能的麻烦
- 迫使语言特性的设计者应对现实世界的困难应用
线程和锁模型需要使用某种形式的同步来避免竞争条件。C++11 为此提供了标准的 mutex
(互斥锁):
mutex m; // 控制用的互斥锁
int sh; // 共享的数据
void access ()
{
unique_lock<mutex> lck {m}; // 得到互斥锁
sh += 7; // 操作共享数据
} // 隐式释放互斥锁
unique_lock
是一个 RAII 对象,确保用户不会忘记在这个 mutex
上调用 unlock()
。
这些锁对象还提供了一种防止最常见形式的死锁的方法:
void f()
{
// ...
unique_lock<mutex> lck1 {m1,defer_lock}; // 还未得到 m1
unique_lock<mutex> lck2 {m2,defer_lock};
unique_lock<mutex> lck3 {m3,defer_lock};
// ...
lock(lck1,lck2,lck3); // 获取所有三个互斥锁
// ... 操作共享数据 ...
} // 隐式释放所有互斥锁
这里,lock()
函数同时
获取所有 mutex
并隐式释放所有互斥锁(RAII(§2.2.1))。C++17 有一个更优雅的解决方案(§8.4)。
线程库是由 Pete Becker(Dinkumware)在 2004 年首次为 C++0x 提出的 [Becker 2004],它基于 Dinkumware 对 Boost.Thread [Boost 1998–2020] 所提供的接口的实现。在同一次会议上(华盛顿州 Redmond 市,2004 年 9 月)提出了第一个关于内存模型的提案 [Alexandrescu et al. 2004],这可能不是巧合。
最大的争议是关于取消操作,即阻止线程运行完成的能力。基本上,委员会中的每个 C++ 程序员都希望以某种形式实现这一点。然而,C 委员会在给 WG21 的正式通知 [WG14 2007] 中反对线程取消,这是唯一由 WG14(ISO C 标准委员会)发给 WG21 的正式通知。我指出,但是 C 语言没有用于系统资源管理和清理的析构函数和 RAII
。管理 POSIX 的 Austin Group 派出了代表,他们 100% 反对任何形式的这种想法,坚称取消既没有必要,也不可能安全进行。事实上 Windows 和其他操作系统提供了这种想法的变体,并且 C++ 不是 C,然而 POSIX 人员对这两点都无动于衷。在我看来,恐怕他们是在捍卫自己的业务和 C 语言的世界观,而不是试图为 C++ 提出最好的解决方案。缺乏标准的线程取消一直是一个问题。例如,在并行搜索(§8.5)中,第一个找到答案的线程最好可以触发其他此类线程的取消(不管是叫取消或别的名字)。C++20 提供了停止令牌机制来支持这个用例(§9.4)。
4.1.3 期值(future)
一个类型安全的、标准的、类似 POSIX/Windows 的线程库是对正在使用的不兼容的 C 风格库的重大改进,但这仍然是 1980 年代风格的底层编程。一些成员,特别是我,认为 C++ 迫切需要更现代、更高层次的东西。举例来说,Matt Austern(谷歌,之前代表 SGI)和我主张消息队列(通道
)和线程池。这些意见没有什么进展,因为有反对意见说没有时间来做这些事情。我恳求并指出,如果委员会中的专家不提供这样的功能,他们最终将不得不使用由我的学生匆匆炮制的
功能。委员会当然可以做得比这好得多。如果你不愿意这样做,请给我一种方法,就一种方法,在没有显式同步的情况下在线程之间传递信息!
委员会成员分为两派,一派基本上想要在类型系统上有改进的 POSIX(尤其是 P.J. Plauger),另一派指出 POSIX 基本上是 1970 年代的设计,每个人
都已经在使用更高层次的功能。在 2007 年的 Kona 会议上,我们达成了一个妥协:C++0x(当时仍期望会是 C++09)将提供 promise
和 future
,以及异步任务的启动器 async()
,允许但不需要线程池。和大多数折中方案一样,Kona 妥协
没有让任何人满意,还导致了一些技术问题。然而,许多用户认为它是成功的——大多数人不知道这当时是一种妥协——并且这些年来,已经出现了一些改进。
最后,C++11 提供了:
future
——一个句柄,通过它你可以从一个共享的单对象缓冲区中get()
一个值,可能需要等待某个promise
将该值放入缓冲区。promise
——一个句柄,通过它你可以将一个值put()
到一个共享的单对象缓冲区,可能会唤醒某个等待future
的thread
。packaged_task
——一个类,它使得设置一个函数在线程上异步执行变得容易,由future
来接受promise
返回的结果。async()
——一个函数,可以启动一个任务并在另一个thread
上执行。
使用这一切的最简单方法是使用 async()
。给定一个普通函数作为参数,async()
在一个 thread
上运行它,处理线程启动和通信的所有细节:
double comp4(vector<double>& v)
// 如果 v 足够大则会产生多个任务
{
if (v.size()<10000) // 值得用并发机制吗?
return accum(v.begin(),v.end(),0.0);
auto v0 = &v[0];
auto sz = v.size();
auto f0 = async(accum,v0,v0+sz/4,0.0); // 第一部分
auto f1 = async(accum,v0+sz/4,v0+sz/2,0.0); // 第二部分
auto f2 = async(accum,v0+sz/2,v0+sz*3/4,0.0); // 第三部分
auto f3 = async(accum,v0+sz*3/4,v0+sz,0.0); // 第四部分
return f0.get()+f1.get()+f2.get()+f3.get(); // 收集结果
}
async
将代码包装在 packaged_task
中,并管理 future
及其传输结果的 promise
的设置。
值或异常都可以通过这样一对 future
/promise
从一个 thread
传递到另一个 thread
。例如:
X f(Y); // 普通函数
void ff(Y y, promise<X>& p) // 异步执行 f(y)
{
try {
X res = f(y); // ... 给 res 计算结果 ...
p.set_value(res);
}
catch (...) { // 哎呀:没能计算出 res
p.set_exception(current_exception());
}
}
为简单起见,我没有使用参数的完美转发(§4.2.3)。
对应 future
的 get()
现在要么得到一个值,要么抛出一个异常——与 f()
的某个等效同步调用完全一样。
void user(Y arg)
{
auto pro = promise<X>{};
auto fut = pro.get_future();
thread t {ff,arg,ref(pro)}; // 在不同线程上运行 ff
// ... 做一会别的事情 ...
X x = fut.get();
cout << x.x << '\n';
t.join();
}
int main()
{
user(Y{99});
}
标准库的 packaged_task
自动化了这个过程,可以将普通函数包装成一个函数对象,负责 promise
/future
的自动配置并处理返回和异常。
我曾希望这会产生一个由线程池支持的工作窃取(work-stealing)的实现,但我还是失望了。
另见(§8.4)。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论