返回介绍

4.1 C++11:并发支持

发布于 2024-08-19 12:44:37 字数 32651 浏览 0 评论 0 收藏 0

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)将提供 promisefuture,以及异步任务的启动器 async(),允许但不需要线程池。和大多数折中方案一样,Kona 妥协没有让任何人满意,还导致了一些技术问题。然而,许多用户认为它是成功的——大多数人不知道这当时是一种妥协——并且这些年来,已经出现了一些改进。

最后,C++11 提供了:

  • future——一个句柄,通过它你可以从一个共享的单对象缓冲区中 get() 一个值,可能需要等待某个 promise 将该值放入缓冲区。
  • promise——一个句柄,通过它你可以将一个值 put() 到一个共享的单对象缓冲区,可能会唤醒某个等待 futurethread
  • 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)。

对应 futureget() 现在要么得到一个值,要么抛出一个异常——与 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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文