《C++编程规范:101条规则、准则与最佳实践》——2.8懂得何时和如何进行并发性编程

简介:

本节书摘来自异步社区出版社《C++编程规范:101条规则、准则与最佳实践》一书中的第2章,第2.8节,作者:【加】Herb Sutter , 【罗】Andrei,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.8懂得何时和如何进行并发性编程

摘要
安线全程地[4]:如果应用程序使用了多个线程或者进程,应该知道如何尽量减少共享对象(见第10条),以及如何安全地共享必须共享的对象。

讨论
线程处理是一个大课题。之所以撰写本条,是因为这个课题很重要,需要明确地予以阐述,但是单凭一个条款显然无法做出公允的评价,所以我们只简单地概述几个要点。更多的细节和具体技术,参阅本条的参考文献。其中最重要的问题是避免死锁、活锁(livelock)[5]和恶性的竞争条件(包括加锁不足导致的崩溃)。

C++标准关于线程未置一词。然而,C++经常而且广泛地用于编写可靠的多线程代码。如果应用程序需要跨线程共享数据,请如下安全行事。

参考目标平台的文档,了解该平台的同步化原语。典型的原语包括从轻量级的原子整数操作到内存障栅(memory barrier)[6],再到进程内和跨进程的互斥体。

最好将平台的原语用自己设计的抽象包装起来。在需要跨平台移植性的时候,这样做尤其有益。或者,也可以使用程序库(比如pthreads [Butenhof 97])为我们代劳。

确保正在使用的类型在多线程程序中使用是安全的。说得具体一些,就是类型必须至少做到以下两个方面。

保证非共享的对象独立。两个线程能够自由地使用不同的对象,无需调用者的任何特殊操作。

记载调用者在不同线程中使用该类型的同一个对象需要做什么。许多类型要求对这种共享对象进行串行访问,但是有些类型却不要求这样。后者通常要么从设计中去掉加锁需求,要么自己进行内部加锁,无论哪种情况,仍然需要留意内部加锁粒度的局限。

请注意,无论类型是字符串类型,还是STL容器比如vector,或者任何其他类型,上面的原则都适用。(我们留意到有些书的作者曾经给出建议,暗示标准容器有特殊性。其实并非如此,容器也只不过是一种对象而已。)说得具体一些,如果要在多线程程序中使用标准库组件(例如string,容器),如前所述,应该参考标准库实现的文档,了解是否支持多线程。

在自己编写可用于多线程程序的类型时,也必须完成两项任务。首先,必须保证不同线程能够不加锁地使用该类型的不同对象(注意:具有可修改的静态数据的类型通常不能保证这一点)。其次,必须在文档中说明使用者在不同线程中使用该类型的同一个对象需要做什么,基本的设计问题是如何在类及其客户之间分配正确执行(即无竞争和无死锁地执行)的职责。主要的选择有下列几个方面。

外部加锁:调用者负责加锁。在这种选择下,由使用对象的代码负责了解是否跨线程共享了对象,如果是,还要负责串行化所有对该对象的使用。例如,字符串类型通常使用外部加锁(或者不变性,见第三种选择)。

内部加锁:每个对象将所有对自己的访问串行化,通常采用为每个公用成员函数加锁的方法来实现,这样调用者就可以不用串行化对象的使用了。例如,生产者/消费者队列通常使用内部加锁,因为它们存在的目的就是被跨线程共享,而且它们的接口就是为了在单独的成员函数调用(Push, Pop)期间能够进行适当的层次加锁而设计的。更一般的情况下,需要注意,只有在知道了以下两件事情之后这个选项才适用。
第一,必须事先知道该类型的对象几乎总是要被跨线程共享的,否则到头来只不过进行了无效加锁。请注意大多数类型都不会遇到这种情况,即使是在多线程处理分量很重的程序中,大多数对象也不会被跨线程共享(这是好现象,见第10条)。
第二,必须事先知道成员函数级加锁的粒度是合适的,而且能满足大多数调用者的需要。具体而言,类型接口的设计应该有利于粗粒度的、自给自足的操作。如果调用者总是需要对多个而不是一个操作加锁,那么就不能满足需要了,只能通过增加更多的(外部)锁,将单独加锁的函数组装成一个更大规模的已加锁工作单位。例如一个容器类型,如果它返回一个迭代器,则迭代器可能在用到之前就失效了;如果它提供find之类的能返回正确答案的成员算法,那么答案可能在用到之前就出错了;如果它的用户想要编写这样的代码:if( c.empty() ) c.push_back(x);,同样会出现问题。(更多的例子,参阅 [Sutter02]。)在这些情况下,调用者需要进行外部加锁,以获得生存期能够跨越多个单独成员函数调用的锁,这样一来每个成员函数的内部加锁就毫无用武之地了。因此,内部加锁是绑定于类型的公用接口的:在类型的各个单独操作本身都完整时,内部加锁才适用;换句话说,类型的抽象级别不仅提升了,而且表达和封装得更加精确了(比如,以生产者-消费者队列的形式,而不是普通的vector)。将多个原语操作结合起来,形成粒度更粗的公开操作,不仅可以确保函数调用有意义,而且可以确保调用简单。如果原语的结合是不能确定的,而且也无法将合理的使用场景集合集中到一个命名操作中,那么有两种选择:一是使用基于回调的模型(即让调用者调用一个单独的成员函数,但是以一个命令或者函数对象的形式传入它们想要执行的任务,见第87条到第89条);二是在接口中以某种方式暴露加锁。

不加锁的设计,包括不变性(只读对象):无需加锁。将类型设计得根本无需加锁是可能的(参阅本条的参考文献)。常见的例子是不变对象,它无需加锁,因为它从不发生变化。例如,对于一个不变的字符串类型而言,字符串对象一旦创建就不会改变,每个字符串操作都会创建新的字符串。

请注意,调用代码应该不需要知道你的类型的实现细节(见第11条)。如果类型使用了底层数据共享技术[如写时复制(copy-on-write)],那么你就不需要为所有可能的线程安全性问题负责了,但是必须负责恢复“恰到好处的”线程安全,以确保调用代码在履行其通常职责时仍是正确的:类型必须能够尽可能地安全使用,如果它没有使用隐蔽的实现共享(见[Sutter04c])。前面已经提到,所有正确编写的类型都必须允许在不同线程中无需同步便可操作不同的可见对象。

如果编写的是一个将要广泛使用的程序库,那么尤其要考虑保证对象能够在前面叙述的多线程程序中安全使用,而且又不会增加单线程程序的开销。例如,如果你正在编写的程序库包含一个使用了写时复制的类型,并且因而必须至少进行某种内部加锁,那么最好安排加锁在程序库的单线程编译版本中消失[#ifdef和空操作(no-op)实现是常见的策略]。

在获取多个锁时,通过安排所有获取同样的锁的代码以相同的顺序获取锁,可以避免死锁情况的发生。(释放锁则可以按照任意顺序进行。)解决方案之一,是按内存地址的升序获取锁,地址恰好提供了一个方便、唯一而且是应用程序范围的排序。

参考文献
[Alexandrescu02a] ● [Alexandrescu04] ● [Butenhof97] ● [Henney00] ● [Henney01] ● [Meyers04] ● [Schmidt01] ● [Stroustrup00] §14.9 ● [Sutter02] §16 ● [Sutter04c]

相关文章
|
7月前
|
存储 缓存 C++
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
C++ 标准模板库(STL)提供了一组功能强大的容器类,用于存储和操作数据集合。不同的容器具有独特的特性和应用场景,因此选择合适的容器对于程序的性能和代码的可读性至关重要。对于刚接触 C++ 的开发者来说,了解这些容器的基础知识以及它们的特点是迈向高效编程的重要一步。本文将详细介绍 C++ 常用的容器,包括序列容器(`std::vector`、`std::array`、`std::list`、`std::deque`)、关联容器(`std::set`、`std::map`)和无序容器(`std::unordered_set`、`std::unordered_map`),全面解析它们的特点、用法
C++ 容器全面剖析:掌握 STL 的奥秘,从入门到高效编程
|
6月前
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
323 6
|
11月前
|
安全 程序员 编译器
【实战经验】17个C++编程常见错误及其解决方案
想必不少程序员都有类似的经历:辛苦敲完项目代码,内心满是对作品品质的自信,然而当静态扫描工具登场时,却揭示出诸多隐藏的警告问题。为了让自己的编程之路更加顺畅,也为了持续精进技艺,我想借此机会汇总分享那些常被我们无意间忽视却又导致警告的编程小细节,以此作为对未来的自我警示和提升。
1187 98
|
11月前
|
存储 C++ UED
【实战指南】4步实现C++插件化编程,轻松实现功能定制与扩展
本文介绍了如何通过四步实现C++插件化编程,实现功能定制与扩展。主要内容包括引言、概述、需求分析、设计方案、详细设计、验证和总结。通过动态加载功能模块,实现软件的高度灵活性和可扩展性,支持快速定制和市场变化响应。具体步骤涉及配置文件构建、模块编译、动态库入口实现和主程序加载。验证部分展示了模块加载成功的日志和配置信息。总结中强调了插件化编程的优势及其在多个方面的应用。
1072 173
|
7月前
|
存储 机器学习/深度学习 编译器
【C++终极篇】C++11:编程新纪元的神秘力量揭秘
【C++终极篇】C++11:编程新纪元的神秘力量揭秘
|
7月前
|
存储 算法 C++
深入浅出 C++ STL:解锁高效编程的秘密武器
C++ 标准模板库(STL)是现代 C++ 的核心部分之一,为开发者提供了丰富的预定义数据结构和算法,极大地提升了编程效率和代码的可读性。理解和掌握 STL 对于 C++ 开发者来说至关重要。以下是对 STL 的详细介绍,涵盖其基础知识、发展历史、核心组件、重要性和学习方法。
|
7月前
|
存储 安全 算法
深入理解C++模板编程:从基础到进阶
在C++编程中,模板是实现泛型编程的关键工具。模板使得代码能够适用于不同的数据类型,极大地提升了代码复用性、灵活性和可维护性。本文将深入探讨模板编程的基础知识,包括函数模板和类模板的定义、使用、以及它们的实例化和匹配规则。
|
10月前
|
消息中间件 存储 安全
|
11月前
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
173 11
|
11月前
|
算法 编译器 C++
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
【C++篇】领略模板编程的进阶之美:参数巧思与编译的智慧
222 2