Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

42.考虑以emplacement代替insertion

Posted on 2018-07-19 | In Effective Modern C++

问题实例

 
如果当前有一个容器,其内部元素类型为std::string,当我们通过插入函数(insert、push_front、push_back或者是std::forward_list的insert_after)传递一个std::string类型的元素到容器中时,理论上容器内部将会存在这个元素。之所以说是“理论上”,是因为可能未必如此,考虑下述代码:

1
2
std::vector<std::string> vs; // container of std::string
vs.push_back("xyzzy"); // add string literal

显然,我们插入的并不是一个string而是一个字符串常量,也就是说我们并没有插入容器持有的元素类型。


性能分析

 
std::vector对左值和右值均进行了重载,其形式大致如下:

1
2
3
4
5
6
7
8
template <class T,class Allocator = allocator<T> >
class vector {
public:
…
void push_back(const T& x); // insert lvalue
void push_back(T&& x); // insert rvalue
…
};

在调用语句
1
vs.push_back("xyzzy");

编译器将发现实参类型(const char [6])与push_back声明的形参类型(reference-to-std::string)并不匹配,因此它们通过从字符串常量创建临时std::string对象来解决不匹配问题,并将该临时对象传递给push_back。 因此,调用语句可看作为:
1
vs.push_back(std::string("xyzzy")); // create temp. std::string and pass it to push_back

尽管这段程序能够被正确编译和运行,但实际上存在着效率问题。在vector中创建一个元素必然会调用一次构造函数,但上述程序调用了2次string构造函数,并且还调用了一次string析构函数:

  1. 字符串常量创建了匿名string对象,此处发生了一次string构造,该匿名对象是一个右值。
  2. 该临时对象被传递给push_back后触发其右值重载版本,因此在vector内部移动构造了一个string对象,此处为第二次构造。
  3. push_back完成后临时对象被析构,此时触发析构函数。

emplace_back

 
通过上述分析可以得知,如果我们能够在vector内部直接利用参数构造对象,则无需付出临时对象的构造与析构成本。为了达到这目的,我们应当将push_back替换为emplace_back,后者的功能正是将利用传入参数直接在容器内部完成构造,根本不需要任何临时对象插手:

1
vs.emplace_back("xyzzy"); // construct std::string inside vs directly from "xyzzy"

emplace_back使用完美转发,因此只要不使用Item30所述的任何禁形,开发者可以通过emplace_back传递任何数量、任何类型的参数,例如我们可以这样撰写:
1
vs.emplace_back(50, 'x'); // insert std::string consisting of 50 'x' characters

所有支持push_back的标准库容器都支持emplace_back,同理,所有支持push_front的标准库容器都支持emplace_front,此外,所有支持insert的标准库容器(除了std::forward_list和std::array)都支持emplace。关联容器以emplace_hint以补足传统的insert函数,而std::forward_list使用emplace_after来弥补其insert_after。

emplace的优势在于相对于insert,它的接口更加灵活。insert需要插入对象,而emplace值需要构造函数的参数即可直接在容器内部完成构造,这一特性避免了临时对象的生成。如果传递的就是容器元素类型,此时insert和emplace均不会产生临时对象,因此可以认为二者等价。


emplace的使用

 
emplace似乎能够完成insert可以完成的一切任务,并且在某些场合具备更高的性能,那我们为什么不直接使用emplace取代insert呢?
唉,理想很丰满,现实很骨感。目前标准库的实现在某些方面与我们的设想不大一致,虽然大多数场景下emplace的确优于insert,但可悲的是,某些场景下insert要比emplace快。此类场景不太好判断,因为这取决于传递的参数类型、使用的容器、插入容器的位置、容器元素类型构造函数的异常安全性、容器是否允许插入重复的值、要插入的元素是否已经在容器中了等等。


emplace的使用条件

上文描述的很是含混,想必很难让读者满意,因此我们最终还是给出一组判断依据:如果以下条件全部符合,则emplace必然比insert高效:

  1. 元素必须被构造于容器内部,而非采取赋值形式。
    现在我们简单地修改问题实例,令newString占据已存在元素的内存:

    1
    2
    3
    std::vector<std::string> vs; // as before
    … // add elements to vs
    vs.emplace(vs.begin(), "xyzzy"); // add "xyzzy" to beginning of vs

    很少有编译器会将上述代码实现为构造一个std::string对象然后放到vs[0]位置上,大多数编译器的实现是进行移动赋值,将传入的参数直接移动赋值到目标位置。但是移动赋值需要传入的是一个对象,这就需要构建一个临时对象,然后移动过去,等操作完成后再将该对象析构。显然这种做法并没有发挥emplace的优势,我们依旧需要生成一个临时对象。
    当然少有代码能够明确判断出当前执行的是构造还是赋值,但依然存在一条通用准则以供参考。基于节点的容器往往倾向于采用构造方式,并且大多数标准库容器均基于节点(std::vector、std::deque、std::string并非基于节点)。对于非节点式容器而言,push_back几乎总是触发构造(当然,deque的push_front也总是构造)。

  2. 传递的参数类型和容器中的类型不同
    emplace的优势便在于避免临时对象的构造和析构,如果传递的参数类型和容器中的类型相同,则不会产生临时对象,因此emplace也就毫无优势可言。

  3. 容器并不因元素重复而拒绝接纳
    这意味着容器允许内部元素重复,或者我们添加的大部分元素都是唯一的。为了检测某值是否已经存在于容器内部,emplace通常会创建一个具有新值的节点,以便将此节点的值与现有容器节点进行比较。如果不存在重复则加入该节点,否则emplace将负责析构该节点。一旦节点被析构,这意味着之前的构造成本均付诸东流。emplace构造节点的频率要高于insert,因此在使用前最好判断一下该条件。


资源管理

在上述条件之外还有两点需要考虑,其一在于资源管理。假设当前存在一个容器,其元素类型为std::shared_ptr<Widget>:

1
std::list<std::shared_ptr<Widget> > ptrs;

并且我们想添加一个具备自定义删除器的shared_ptr,由于此种情况无法通过make_shared获取shared_ptr(见Item27),因此必须使用new获取原始指针。
假定现有删除器如下:
1
void killWidget(Widget* pWidget);

那么insert版本大致如下所示:
1
ptrs.push_back(std::shared_ptr<Widget>(new Widget, killWidget));

当然也可以写为如下形式:
1
ptrs.push_back({ new Widget, killWidget });

无论哪种方式,在调用push_back之前都会构造一个临时的std::shared_ptr,原因在于push_back的形参是一个reference-to-shared_ptr,因此必须存在一个shared_ptr才行。emplace可以规避临时对象的产生,但我们可能需要为此付出更大的代价。试考虑以下事件序列

  1. 在上述的任一调用期间,编译器构造了一个临时的std::shared_ptr<Widget>对象以保存由“new Widget”产生的原始指针,将此对象称为temp。
  2. push_back通过引用获取temp,在分配节点以保存临时副本期间,抛出了out-of-memory异常。
  3. 当异常传播出push_back时,temp被析构,作为当前唯一指向Widget的sptr,它将自动调用killWidget以释放Widget。

可以看出,push_back版本具备一定的异常安全性,即使抛出异常也能够保证资源不被泄露。

假若当前以emplace替换insert版本:

1
ptrs.emplace_back(new Widget, killWidget);

  1. 通过new Widget产生的裸指针,经过完美转发后进入push_back函数内部,然后容器开始分配内存用于构造新元素,在分配内存时抛出out-of-memory异常。
  2. emplace_back发生异常后指向Widget的原始指针析构,但其所指向的内存还没有释放,已经无法获取Widget占用的内存,触发资源泄露事件。

显然,使用emplace_back会导致程序可能产生内存泄漏,对于需要自定义删除器的unique_ptr亦同样存在这一问题,这也从侧面反映出std::make_shared和std::make_unique的重要性。

对于持有资源管理类的容器(例如std::list<std::shared_ptr<Widget> >)来说,insert确保在获取资源与资源管理类初始化之间不存在任何阻塞,而emplace的完美转发延迟了资源管理对象的创建(直到容器完成内存分配才执行构造),因此在异常触发时易于导致内存泄漏。所有标准库容器均存在此类问题,因此你必须在效率与异常安全性之间做出权衡。

在Item21中我们便已提及资源管理类初始化语句必须独立,我们不应当将new Widget这样的表达式作为参数传递给emplace_back、push_back,或是其他任何函数。正确的做法是完成资源管理对象构造后将其传递给emplace_back或push_back:

1
2
3
4
std::shared_ptr<Widget> spw(new Widget,killWidget);  // create Widget and have spw manage it
ptrs.push_back(std::move(spw)); // add sp
std::shared_ptr<Widget> spw(new Widget, killWidget);
ptrs.emplace_back(std::move(spw));

上述两种方案均存在spw对象的构造和析构的开销,而我们使用emplace_back替换push_back的目的就是为了避免临时对象的构造和析构开销,而spw与临时对象等价。但使用emplace_back往容器中插入资源管理对象时,必须要以某种策略保证资获取与资源管理对象构造之间不被干涉,这也就是spw对象存在的意义。


emplace与explicit的交互

第二个因素是和explicit构造函数交互相关。C++11引入了正则表达式,下述程序试图创建一组正则表达式对象并将其存放至容器内:

1
std::vector<std::regex> regexes;

出于疏忽,你不小心写下了如下看似毫无意义的程序:
1
regexes.emplace_back(nullptr);

众所周知,正则表达式对象可由字符串(这里并非特指std::string)构造:
1
std::regex upperCaseWord("[A-Z]+");

从string创建std::regex需要较大的运行期成本,为了最大限度地降低开销,以const char *为形参的std::regex构造函数被声明为explicit,因此下述程序无法编译:
1
2
std::regex r = nullptr; // error! won't compile
regexes.push_back(nullptr); // error! won't compile

上述两条语句均需要执行隐式转换,但explict拒绝了此类转换。

然而在对emplace_back的调用中,我们并未明确声明传入std::regex对象,而是正在为std::regex对象传递构造函数参数,这并不被视为隐式转换请求,反而被编译器视为等价于如下程序:

1
std::regex r(nullptr); // compiles

编译通过并不是什么好事,原因在于以nullptr初始化的正则表达式对象行为未知,可能对开发者最好的结局就是运行期崩溃。

暂且抛开push_back与emplace_back,我们将注意力投向这些非常相似的初始化语法,它们将产生不同的结果:

1
2
std::regex r1 = nullptr; // error! won't compile
std::regex r2(nullptr); // compiles

用于初始化r1(使用等号)的语法对应于所谓的拷贝初始化,用于初始化r2的语法(使用小括号,其实也可以使用大括号)产生所谓的直接初始化。拷贝初始化禁止使用显式构造函数,而直接初始化允许,这也就是二者一个可以编译一个不能编译的原因所在。

emplace使用直接初始化,而insert使用拷贝初始化,因此:

1
2
regexes.emplace_back(nullptr); // compiles. Direct init permits use of explicit std::regex ctor taking a pointer
regexes.push_back(nullptr); // error! copy init forbids use of that ctor

综上所述,使用emplace时需要保证传递的实参正确,因为编译器为了找到某种方法将代码有效化会不惜使用显式构造函数。

41.Consider pass by value for copyable parameters that are cheap to move and always copied.

Posted on 2018-07-19 | In Effective Modern C++

(本节标题完美揭示了全部主旨,不宜翻译)

问题实例

 
假定当前存在一个Widget class,其成员函数addName接受一个string并将其添加到内部容器中。一般为了保证效率,我们通常会对左值string执行pass-by-const-reference,对右值执行move:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
void addName(const std::string& newName) // take lvalue;
{ names.push_back(newName); } // copy it
void addName(std::string&& newName) // take rvalue;
{ names.push_back(std::move(newName)); } // move it;
…
private:
std::vector<std::string> names;
};

以上代码运行地非常良好,就是函数可能太多了一点,我们不得不写下两份声明,两份实现,两份文档。。。诸如此类。此外,目标码中将存在两个函数,这无疑会导致程序的占用空间增大。内联会优化这一行为,但你并不能保证内联的必然执行。

另一种方法是使addName成为一个形参为universal reference的函数模板:

1
2
3
4
5
6
7
8
class Widget {
public:
template<typename T> // take lvalues and rvalues;
void addName(T&& newName) {
// copy lvalues,move rvalues;
names.push_back(std::forward<T>(newName));
}
};

这减少了源码的大小,但可能会造成更加恶劣的情况。作为模板,addName的实现通常必须位于头文件中,因此它可能在目标码中生成相当多的函数,原因在于它不仅对lvalue和rvalue产生不同的实例化,它还对std::string和可转换为std::string的类型产生不同的实例化(见Item25)。此外,有些参数类型无法通过universal reference传递(见Item30),如果客户传递了不正确的参数类型,编译器所提出的错误报告可能会令人头大(见Item27)。


解决方案

 
有没有一种函数,既能针对不同情况作出不同处理(copy左值,移动右值),又能只在源码和目标码中仅仅出现一次,并且还不需要使用universal reference?事实上真的有。我们唯一要做的就是摒弃入门时的第一条C++准则——禁止对自定义类型采用值传递形式。对于Addname中的newname这种参数,采用值传递确实是一种正当选择。以下是值传递版本的实现:

1
2
3
4
5
6
class Widget {
public:
void addName(std::string newName) // take lvalue or rvalue; move it
{ names.push_back(std::move(newName)); }
…
};

此代码中唯一不太显然之处就是push_back的实参中采用了std::move。通常情况下,move一般与右值一起使用(见Item25),但在本例中我们可以清楚地看出:

  1. newName是完全独立的对象,更改newName不会影响caller。
  2. 这是newName的最后一次使用,移动它并不影响程序的其他地方。

因此使用move不会产生任何问题。


性能剖析

 
在C++98中,pass-by-value将导致昂贵的性能损耗, pass-by-value意味着无论caller传入的是什么都必须经由copy constructor构造。但在C++11中,addName将仅为lvalue执行拷贝构造。 对于右值,它将执行移动构造。例如在如下语句中:

1
2
3
4
5
6
Widget w;
…
std::string name("Bart");
w.addName(name); // call addName with lvalue
…
w.addName(name + "Jenne"); // call addName with rvalue(see below)

在第一次调用addName时,newName由左值初始化,因此与C++98一样采用拷贝构造。在第二次调用中,newName由一个匿名std::string对象初始化,该对象是一个右值,因此newName由移动构造。一切都如我们所愿。


方案比较

 
前文讨论的三种方案依次如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Widget { // Approach 1:overload for lvalues and rvalues
public:
void addName(const std::string& newName)
{ names.push_back(newName); }
void addName(std::string&& newName)
{ names.push_back(std::move(newName)); }
…
private:
std::vector<std::string> names;
};
class Widget { // Approach 2:use universal reference
public:
template<typename T>
void addName(T&& newName)
{ names.push_back(std::forward<T>(newName)); }
…
};
class Widget { // Approach 3:pass by value
public:
void addName(std::string newName)
{ names.push_back(std::move(newName)); }
…
};

我们可以将前两种称为“by-reference approaches”,因为它们都通过reference传递参数。现有两种测试场景如下:
1
2
3
4
5
6
Widget w;
…
std::string name("Bart");
w.addName(name); // pass lvalue
…
w.addName(name + "Jenne"); // pass rvalue

针对上述三种方案,现有分析如下:

  1. 重载
    无论传入的是左值还是右值,其均被绑定至一个名为newName的引用。将实参绑定至引用没有任何成本。在左值重载函数中,newName被copy至Widget::names中。在右值重载函数中,newName被移动至Widget::names中。总的来说,左值需要一次拷贝,右值需要一次移动。
  2. universal reference
    与重载一样,实参被绑定至引用newName,绑定操作没有成本。由于使用了std::forward,lvalue std::string参数将会被copy到Widget::names中,而ravlue std::string将会被移动到该容器内。总的来说成本与重载一致:左值需要一次拷贝,右值需要一次引用。(在本次分析中我们假设传入的总是string,而非别的类型)。
  3. 传值
    无论传入的是左值还是右值,string对象newName都必须构造,区别在于传入左值则采用拷贝构造,传入右值则采用移动构造。在函数体中,newName将被无条件地移动到Widget::names之中。总的来说,成本总是为一次构造(copy/move)+一次move,这比上两种方案多了一次move。

以传值代替引用的条件

 
现在我们将镜头拉回至标题:Consider pass by value for copyable parameters that are cheap to move
and always copied

该标题实则已经表明了作者的一切立场,以下将一一解读:

  1. 你应当考虑(consider)使用传值。尽管它只需要撰写一次,并且避免了目标码膨胀,但传值比其替代方案成本更高,并且可能存在其他成本(详见下文)。
  2. 传值仅针对可拷贝的对象,move-only类型无法拷贝,在这种情况下,“重载”解决方案只需要一个重载:采用rvalue引用的重载。
    举例而言,当前Widget class内含一个unique_ptr<std::string>对象与一个由于unique_ptr是一个move-only类型,因此重载版本完全可以写为:
    1
    2
    3
    4
    5
    6
    7
    8
    class Widget {
    public:
    …
    void setPtr(std::unique_ptr<std::string>&& ptr)
    { p = std::move(ptr); }
    private:
    std::unique_ptr<std::string> p;
    };
    其调用形式大致类似于:
    1
    2
    3
    Widget w;
    …
    w.setPtr(std::make_unique<std::string>("Modern C++"));
    其总成本为一次移动操作(移动unique_ptr匿名对象至p)。
    如果将该类设计为pass-by-value:
    1
    2
    3
    4
    5
    6
    7
    class Widget {
    public:
    …
    void setPtr(std::unique_ptr<std::string> ptr)
    { p = std::move(ptr); }
    …
    };
    则将首先移动构造ptr,然后再将其移入p中,其成本为右值重载的两本。
  3. 仅在移动操作成本较低时选择pass-by-value,否则多付出的一次move操作将导致得不偿失。
  4. 应当考虑为始终执行copy的参数选择pass-by-value。举例而言,假如在加入容器前需要对字符串长度进行判断,符合条件者方可加入:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Widget {
    public:
    void addName(std::string newName){
    if ((newName.length() >= minLen) &&(newName.length() <= maxLen)){
    names.push_back(std::move(newName));
    }
    }
    …
    private:
    std::vector<std::string> names;
    };
    这种情况下可能会造成不必要的copy操作,因此在使用pass-by-value之前必须确保该参数定会被copy。

赋值拷贝

即使我们真正符合上述条件,pass-by-value也未必是最佳方案。原因在于函数可能会以两种方式拷贝参数:构造(copy构造与move构造)、赋值(copy赋值与move赋值)。
addName成员函数使用构造:其参数newName被传递给vector::push_back,在该函数内部,newName被拷贝构造为vector尾端的一个新元素。本节开篇便分析了这种情况,此时无论传递的是左值还是右值都会导致额外的一次move的开销。
使用赋值copy参数时,情况会变得较为复杂。假设当前存在一个表示密码的类,因为密码可以更改,我们提供了setter函数changeTo,采用pass-by-value策略,该class实现如下:

1
2
3
4
5
6
7
8
class Password {
public:
explicit Password(std::string pwd): text(std::move(pwd)) {} // construct text
void changeTo(std::string newPwd){ text = std::move(newPwd); } // assign text
…
private:
std::string text; // text of password
};

尽管将密码设为纯文本似乎有些不妥,但请忽略它而将焦点关注于可能发生的问题:
1
2
std::string initPwd("Supercalifragilisticexpialidocious");
Password p(initPwd);

上述程序一切正常,相对于重载或universal reference方案,我们只需要多付出一次move成本而已。但用户可能会试图更改密码,例如他们会写下此类程序:
1
2
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);

传递给changeTo的参数是一个左值(newPassword),因此在构造参数newPwd时将触发string的拷贝构造函数,该构造函数分配内存以保存新密码,然后将newPwd移动到text,这会导致原有text占用的内存被释放。因此,changeTo中有两个动态内存管理操作:一个为新密码分配内存,另一个为旧密码释放内存。

但事实上,因为旧密码要长于新密码。所以我们本没有必要执行动态内存分配。倘若当前采用的是重载版本,将不会触发任何内存分配行为(因为采取了赋值构造):

1
2
3
4
5
6
7
8
9
10
11
class Password {
public:
…
// the overload for lvalues can reuse text's memory if text.capacity() >= newPwd.size()
void changeTo(const std::string& newPwd){
text = newPwd;
}
…
private:
std::string text; // as above
};

执行内存动态分配可能要比move操作多花数个数量级的时间,因此pass-by-value在此场景下并不可取。此外,如果旧text长度小于新text的长度,那么无论采取何种方案,内存动态分配所带来的开销均不可避免。因此,参数复制的成本可能会取决于该参数的值。上述分析只适用于值是保存在堆内存中的,需要动态分配内存的类型,例如std::string与std::vector。

此外,上述分析只适用于实参是左值的情况,如果传递的是右值,那么开销还是很低的,因为不需要进行拷贝构造,省却了拷贝构造带来的内存分配开销(内存分配一般仅发生于copy操作之中)。

总而言之,赋值拷贝性能取决于实参类型,左值与右值的比率,是否存在动态内存分配等等。甚至还需要考虑要传入参数的具体实现方案,以std::string举例,如果它使用了SSO优化进行实现,那么赋值操作会将要赋值的内容放到SSO的buffer中,因此情况又将不同。


最终定论

 
通常情况下,最实际的做法是采用”有罪推定”的策略,也就是平时均使用重载或universal reference方案,除非明显pass-by-value的效率更高。

现如今软件总是越快越好,因此pass-by-value未必是一个良好方案,因为避免一次廉价的move开销也相当重要,我们无法明确一个操作会有多少次这样的move操作。例如Widget::addName通过值传递造成了一次额外的move操作,但是这个函数内部又调用了Widget::validateName,这个函数也是值传递的方式,这就又造成了一次额外的move开销,validateName内部如果再调用其他的函数,并且这个函数同样是值传递的方式呢?,这就造成了累加效应,如果使用引用传递的话就不会有这样的累加效应了。


pass-by-value与slicing

 
如果当前存在一个形参为基类对象或派生类对象的函数,此时应当谨慎使用pass-by-value,因为这会去除派生类的所有特性:

1
2
3
4
5
6
7
class Widget { … }; // base class
class SpecialWidget: public Widget { … }; // derived class
void processWidget(Widget w); // func for any kind of Widget,including derived types;
…
SpecialWidget sw;
…
processWidget(sw); // processWidget sees a Widget, not a SpecialWidget!

40.对并发应用std::atomic,对special memory使用volatile

Posted on 2018-07-19 | In Effective Modern C++

前言

 
一般而言,volatile与C++并发编程毫无关联。在别的语言中(例如Java或C#),volatile对于并发编程存在较大帮助,不过时至今日,C++的某些编译器也将其与某些语义相结合应用至并发编程,因此在并发编程一章讨论volatile也情有可原。


atomic

 
在C++11中提供了一个std::atomic类模版,可以具体实例化出int、bool等类型实例,这个实例保证了操作的原子性,可以被其他线程查看到操作后的结果。它的行为类似于应用了mutex,但是性能损耗更小,因为其内部使用了一种特殊的机器指令实现,该模版类的使用实例如下所示:

1
2
3
4
5
std::atomic<int> ai(0); // initialize ai to 0
ai = 10; // atomically set ai to 10
std::cout << ai; // atomically read ai's value
++ai; // atomically increment ai to 11
--ai; // atomically decrement ai to 10

在这些语句的执行期间,其他线程只能读到ai为0、10、11这几种情况,不存在别的可能(假设当前仅有此线程对ai执行修改)。

上述程序有两点值得注意的地方。首先,在std::cout << ai语句中,ai的atomic特性仅仅保证ai的读取是原子的,而不能保证整条语句均具备原子性。在读取ai的值和调用operator<<以将其写入cout之间,可能存在另一个线程已改变了ai的值。 总之,我们需要理解仅在读取ai值时具备原子性。

另外,需要关注的是最后两条语句——aide自增与自减操作,这两条操作均为读-改-写(RMW)行为,但它们均具备原子性。这也是std::atomic的优势所在:一旦构造了一个std::atomic对象,其所有成员函数都将具备原子性。


volatile

 
使用volatile的相应代码在多线程环境下几乎什么都不保证:

1
2
3
4
5
volatile int vi(0); // initialize vi to 0
vi = 10; // set vi to 10
std::cout << vi; // read vi's value
++vi; // increment vi to 11
--vi; // decrement vi to 10

在此代码执行期间,如果其他线程正在读取vi的值,那么将可能获取到任何内容。上述语句直接导致了未定义行为,因为这些语句在修改vi的同时其他线程可能正在读取或写入vi,vi既不具备原子性也不被mutex所保护,这导致了data race。


atomic与volatile比较

RMW

假设现有一个计数器,其值会在多线程内自增,我们首先将其初始化为0:

1
2
std::atomic<int> ac(0); // "atomic counter"
volatile int vc(0); // "volatile counter"

然后在两个同时运行的线程中各自增计数器一次:
1
2
3
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
++ac; ++ac;
++vc; ++vc;

当两个线程结束时,ac的值必然为2,但vc的值则不能确定。自增需要执行三个步骤:读取值、自增读取到的值、将该值写回。ac的三个步骤均具备原子性,但vc则不然,因此vc的自增步骤可能会交错执行,例如:

  1. 线程1读取vc值,读取结果为0
  2. 线程2读取vc值,读取结果为0
  3. 线程1将读取结果自增为1,将其写入vc
  4. 线程2将读取结果自增为1,将其写入vc

因此vc最终结果为1,即使它被自增了两次。这并非是唯一可能的最终结果,事实上,存在数据竞争的代码,其最终结果不可预料。


信息传递

假设当前存在两个任务,第一个任务将会在某个值计算完成后将“已完成”这一信息传递给第二个任务,在Item39只我们曾经提及这种实例可以通过std::atomic<bool>实现。计算task如下所示:

1
2
3
std::atomic<bool> valAvailable(false);
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available

对于阅读上述代码的开发者而言,我们都很明确imptValue赋值应当处于valAvailable的赋值之前,但编译器却不这么想,它们认为二者不具备任何关联性,因而编译器有资格对此进行重排。换而言之,对于赋值序列:
1
2
a = b;
x = y;

编译器可能会将其重排为:
1
2
x = y;
a = b;

即使编译器没有对它们进行重新排序,底层硬件也可能会这样做,因为这种行为可能会优化运行速度。

但std::atomic的出现使得这种重排受到限制,在源代码

1
2
auto imptValue = computeImportantValue(); // compute value
valAvailable = true; // tell other task it's available

atomic不仅仅要求编译器禁止对语句执行重排,它还要求底层硬件不允许对赋值先后顺序执行重排。因此它保证了valAvailable的赋值必然发生在imptValue结束赋值之后。

将valAvailavle声明为volatile则不能保证重排受限:

1
2
3
volatile bool valAvailable(false);
auto imptValue = computeImportantValue();
valAvailable = true; // other threads might see this assignment before the one to imptValue!

因此,别的线程可能会发现在valAvailable在imptValue正确赋值之前便已被赋值。


volatile的用途

 
上述两点——不能保证操作原子性与不能保证重排受限说明了volatile在并发编程中似乎毫无作用,实则不然。volatile目的在于告知编译器它们正在处理行为不正常的内存。

normal memory

“正常”内存的特征是,如果将值写入内存位置,则值将保留在那里,直到它被覆盖为止。因此,如果我有一个int:

1
int x;

并且编译器看到了如下操作:
1
2
auto y = x; // read x
y = x; // read x again

编译器可能会对y的赋值语句视而不见,因为它认为这相对于初始化语句而言是冗余的。
此外,“正常”内存还有一大特征:如果我们将值写入特性位置后再未读取过,然后再次写入该内存位置,则可认为第一次写入从未发生。

综上,对于如下语句:

1
2
3
4
auto y = x; // read x
y = x; // read x again
x = 10; // write x
x = 20; // write x again

编译器可能会将其优化为:
1
2
auto y = x; // read x
x = 20; // write x

你可能会好奇谁会写出这种无聊的程序(技术上我们将其称为redundant load与dead store),事实上开发者确实很少主动撰写类似于上述程序的代码。但在编译器执行模板实例化,内联和各种常见的重新排序优化之后,上述程序俯拾皆是,因此编译器有必要对“正常”内存执行优化。


special memory

特殊内存并不需要上述优化。可能最常见的特殊内存类型是用于内存映射I/O的内存。这种内存可能在与外部设备发生通信,例如外部传感器或显示器,打印机,网络端口等,而不是读取或写入普通存储器(即RAM)。在这种环境下,redundant load程序并非redundant:

1
2
auto y = x; // read x
y = x; // read x again

如果x对应于温度传感器的返回值,则x的第二次读取并不冗余,因为温度可能在第一次和第二次读取之间发生改变。
对于重复写入亦是如此,
1
2
x = 10; // write x
x = 20; // write x again

10与20可能是向外设发送的某种指令序列,盲目对其执行优化可能导致设备行为异常。


volatile与special memory

volatile对编译器的意义是:“不要对这个内存上的操作执行任何优化。”因此,我们应该将位于special memory的data声明为volatile:

1
volatile int x;

如此一来,
1
2
3
4
auto y = x; // read x
y = x; // read x again (can't be optimized away)
x = 10; // write x (can't be optimized away)
x = 20; // write x again

最后我们需要指明的是,y的类型为int,具体原因可见Item2。


atomic在上述环境下的执行

假定当前有代码如下:

1
2
3
4
5
std::atomic<int> x;
auto y = x; // conceptually read x (see below)
y = x; // conceptually read x again (see below)
x = 10; // write x
x = 20; // write x again

前文已知,编译器可能会将其优化为:
1
2
auto y = x; // conceptually read x (see below)
x = 20; // write x

但事实上,如果x是atomic,上述代码无法编译:
1
2
auto y = x; // error!
y = x; // error!

原因在于atomic的copy操作被设为delete。试想一下,如果atomic对象能够拷贝,因为x是atomic,因此y也将被推衍为atomic对象。前文已经描述过,atomic对象的所有操作均具备原子性。但如果需要从x拷贝构造出y,编译器必须生成能够在单一原子操作内读取x与写入y的代码,这无法在底层硬件上实现,因此atomic并不支持copy构造。基于同样的理由,atomic的copy assignment也被禁止使用,因此上述程序中y=x无法编译。此外,移动操作并没有在atomic中显式声明,根据Item17所提及的规则,atomic亦不支持移动操作。

事实上我们可以利用x来初始化y或给y赋值,不过这需要使用atomic的load与store成员函数,load成员函数以原子方式读取std::atomic的值,而store成员函数以原子方式写入atomic。因此atomic对象的初始化与赋值应当被写为:

1
2
std::atomic<int> y(x.load()); // read x
y.store(x.load()); // read x again

当然上述语句也清楚表明它们是复合式语句,并不具备原子性。因此编译器可能会对其执行优化,优化策略是将x的值存入寄存器省得读取其两次:
1
2
3
register = x.load(); // read x into register
std::atomic<int> y(register); // init y with register value
y.store(register); // store register value into y

显然最终我们只对x读取了一次,这在special memory中必须避免。


总结

  1. std::atomic可以在不使用mutex的前提下在并发编程中安全访问数据。
  2. volatile适用于禁止编译器执行优化的内存。

39.对于一次性事件通信考虑采用void future

Posted on 2018-07-18 | In Effective Modern C++

前言

 
有时我们需要一个task告诉第二个异步运行的task当前已发生特定事件,因为第二个task可能在该事件尚不明确前无法继续。这些特定事件可能是某个数据结构已被初始化,又或者是某项计算已被完成,又或者是某个关键传感器值已被采集得到,那么我们应当采取何种方式以完成这种线程间通信?


条件变量

 
针对上个问题,使用条件变量(condvar)是一种非常显然的方案。我们将task分为两种,检测条件是否符合的detecting task与针对条件作出反应的reacting task,那么实现通信只需要通过一种简单的策略:reacting task等待条件变量的传入,而探测线程在事件触发时传递条件变量:

1
2
std::condition_variable cv; // condvar for event
std::mutex m; // mutex for use with cv

detecting task可以写为:
1
2
… // detect event
cv.notify_one(); // tell reacting task

如果需要告知多个reacting task,则将notify_one改为notify_all即可。

reacting task的实现较为繁琐一些,因为在调用在condvar上调用wait之前,它必须通过std::unique_lock一个mutex(这种手法在线程库中相当常见,通过std::unique_lock锁定互斥锁的需求只是C++11 API的一部分)。以下将给出概念上的实现方案:

1
2
3
4
5
6
7
8
… // prepare to react
{ // open critical section
std::unique_lock<std::mutex> lk(m); // lock mutex
cv.wait(lk); // wait for notify; this isn't correct!
… // react to event(m is locked)
} // close crit. section;
// unlock m via lk's dtor
… // continue reacting(m now unlocked)

上述代码存在一些问题,第一个问题在于我们是否需要使用mutex。mutex用于控制对共享数据的访问,但detecting task与reacting task完全有可能并不需要使用它。举例而言,例如,detecting task可能负责初始化全局数据结构以供reacting task使用。如果detecting task在初始化之后再不访问数据结构,并且reacting task在初始化完成前从不试图访问数据结构,那么我们没有任何理由需要使用mutex。

除了mutex的使用必要性之外,上述程序还存在两个问题:

  1. 如果detecting task在reacting task wait之前便已通知,那么reacting task将一直悬挂。
  2. wait语句存在虚假唤醒问题
    绝大多数线程API(并不仅仅只有C++)都存在条件变量尚未通知便已唤醒的情况,这被称为虚假唤醒。为了保证正确性,大多数程序往往在唤醒后第一时间确认条件变量是否通知,因此我们可以用lambda表征检测,然后将其传递给wait:
    1
    cv.wait(lk,[]{ return whether the event has occurred; });
    但这要求reacting task能够判定条件是否触发,而我们本来就是因为reacting task无法分辨而加入了条件变量。

共享布尔量

 
对于大多数开发者来说,他们可能会选择加入共享bool量来完成任务。该布尔量被初始化为false,当detecting task明确事件触发后将其置为true:

1
2
3
td::atomic<bool> flag(false); // shared flag; see Item 40 for std::atomic
… // detect event
flag = true; // tell reacting task

如此一来reacting task只需要轮询flag即可明确事件是否发生:
1
2
3
… // prepare to react
while (!flag); // wait for event
… // react to event

这种方法没有任何基于condvar设计的缺点,但它所花费的成本太高。在task轮询的过程中task处于运行状态(尽管大多数时刻都是阻塞状态),因此它需要占用一个硬件线程,并且因此将产生context switch成本。condvar不存在这种缺陷,因为线程处于真正的阻塞状态。


condvar与bool的结合

 
在此设计中,flag指示事件是否发生,并且通过mutex完成对flag的访问,因此flag不再需要std::atomic。此时的detecting task如下所示:

1
2
3
4
5
6
7
8
9
std::condition_variable cv; // as before
std::mutex m;
bool flag(false); // not std::atomic
… // detect event
{
std::lock_guard<std::mutex> g(m); // lock m via g's ctor
flag = true; // tell reacting task (part 1)
} // unlock m via g's dtor
cv.notify_one(); // tell reacting task(part 2)

reacting task实现如下所示:
1
2
3
4
5
6
7
8
… // prepare to react
{ // as before
std::unique_lock<std::mutex> lk(m); // as before
cv.wait(lk, [] { return flag; }); // use lambda to avoid spurious wakeups
… // react to event
// (m is locked)
}
… // continue reacting(m now unlocked)

这一设计完全规避了之前提及的所有问题,但总的看来似乎仍然存在某些多余的开销,代码不够明晰流畅。


void future

 
另一种方法是通过让reacting task等待detecting task设置的future来规避condvar、mutex、flag的使用。 Item38指出,发送端是std::promise且接收端是future的通信信道完全可以用于双端通信。这项设计相当简单,detecting task持有std::promise对象(即通信信道的写入端),并且reacting task持有相应的future。当特定事件触发时,detecting task设定std::promise(即写入通信信道)。与此同时,reacting task在其future上执行wait。wait将阻塞reacting task直至std::promise已完成设定。

std::promise和future(包括std::future与std::shared_future)都是需要类型参数的模板,该类型参数指明需要通过通信信道发送的数据类型。然而在本次实例中,并没有任何需要传达的数据,reacting task唯一感兴趣的是其future是否已被设。因此本次数据类型为void,如此一来,即使reacting task不会从detecting task接收到任何数据,通信信道亦允许reacting task通过future判断事件是否触发。

综上,本设计有promise定义如下:

1
std::promise<void> p; // promise for communications channel

detecting task可写为:
1
2
… // detect event
p.set_value(); // tell reacting task

reacting task可写为:
1
2
3
… // prepare to react
p.get_future().wait(); // wait on future corresponding to p
… // react to event


void future的不足

 
首先,Item38中我们便已明确,std::promise与future之间存在着共享状态,而共享状态往往动态分配,动态分配带来了堆分配与释放的开销。

此外,std::promise和future之间的通信通道是一次性机制:它不能重复使用,这与基于condvar和flag的设计有着明显区别,此二者均可用于多次通信。


void future与线程暂停技术

 
假设你需要暂停一个线程,使用void future的设计是一个合理的选择。以下是该技术的核心:

1
2
3
4
5
6
7
8
9
std::promise<void> p;
void react(); // func for reacting task
void detect(){ // func for detecting task
std::thread t([]{p.get_future().wait();react();});// suspend t until future is set
… // here, t is suspended prior to call to react
p.set_value(); // unsuspend t (and thus call react)
… // do additional work
t.join(); // make t unjoinable (see Item 37)
}

为了保证thread在所有路径均为unjoinable,上述程序可被改写为:
1
2
3
4
5
6
void detect(){
ThreadRAII tr(std::thread([]{p.get_future().wait();react();}),ThreadRAII::DtorAction::join);
… // thread inside tr is suspended here
p.set_value(); // unsuspend thread inside tr
…
}

上述代码看似完美,但实际上存在问题。若在第一个”…”处抛出异常,则p将永远无法完成设定,因此tr将永远处于无法完成的状态。(作者将如何解决作为习题留给读者,我第一眼想到的方案是try catch,在catch语句中析构tr)。

多个reacting task的暂停

其关键在于使用std::shared_future以代替std::future。std::future的share成员函数将其共享状态的所有权转移到share生成的std::shared_future对象,本实现唯一的微妙之处即为每个reacting thread都需要自己的std::shared_future副本来引用共享状态,因此从share获取的std::shared_future由运行在reacting thread上的lambda按值捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::promise<void> p; // as before
void detect(){ // now for multiple reacting tasks
auto sf = p.get_future().share(); // sf's type is std::shared_future<void>
std::vector<std::thread> vt; // container for reacting threads
for (int i = 0; i < threadsToRun; ++i) {
vt.emplace_back([sf]{ sf.wait();react(); }); // capture by-value
}
… // detect hangs if this "…" code throws!
p.set_value(); // unsuspend all threads
…
for (auto& t : vt) { // make all threads unjoinable
t.join();
}
}

38.对线程句柄析构行为保持关注

Posted on 2018-07-18 | In Effective Modern C++

前言

 
在上一节中,我们已经解释过一个状态为joinable的std::thread对象对应着一个系统底层线程。基于non-defered task的std::future对象与之类似,也对应着一个系统底层线程,因此,我们可以认为它们都是系统线程的句柄。

同样,我们在上一节中已经解释过析构状态为joinable的thread会导致terminate,但future并非如此,它的析构行为有时看起来像隐式join,有时看起来像隐式detach,又有时并不与它们类似。此外,对future的析构永远不会导致terminate。


共享状态

 
为了探究原理,我们首先对std::future进行一次细致地观察。future可被视为通信信道的一端,callee通过该信道将结果发送给caller。callee(一般其处于异步运行状态)将其计算结果写入通信通道(一般通过std::promise对象),caller使用future来读取该结果。总之,它们的行为大致可以用下图表述,其中虚线箭头表征从callee到caller的信息流:
image_1cim5vb5uis3q851maq7cfjtc9.png-18.2kB
你或许会好奇结果保存在何处。首先肯定不能放在std::promise对象之中,因为结果可能是在调用future对象获取时早已保存起来了,由于callee运行结束时会将std::promise析构,因此promise并不能承担保存结果的重任。

那么能否将结果放在std::future对象之中呢?这同样不行,因为std::future可以用来创建std::shared_future,因此需要将结果保存在每一个future对象中,这一行为将导致该结果被多次拷贝和复制,或许可以使用引用计数的方式来记录当前有多少个future对象关联到这个结果中,但无论何种方式都会造成一定的开销,算不上最优解。

因为与callee关联的对象和与caller关联的对象都不是存储结果的合适位置,所以结果被存储于这二者之外,此位置称为共享状态(shared state)。共享状态通常由heap-based对象表示,但其类型,接口和实现均没有标准形式,全凭标准库作者喜好决定。
我们可以设想callee,caller与共享状态之间的关系如下所示,其中虚线箭头再次表示信息流:
image_1cim69d6g1e0tk7aed01nrg1u37m.png-28kB


共享状态与future析构函数

 
共享状态的存在十分重要,因为future析构函数的行为由与future相关联的共享状态息息相关,具体来说:

  1. 最后一个关联到由std::async启动的non-defered task的共享状态的future,其析构函数将一直阻塞至该task完成为止。
    从本质上来看,这种future的析构函数在运行异步执行task的线程上执行隐式join。
  2. 不满足条件1的future执行正常析构
    对于异步运行的task,这相当于在底层线程上执行隐式detach。对于deferred策略运行的task来说,这相当于这个task将不会运行。

这两条规则听起来很复杂,但实际上很简单,我们真正需要处理的只是一个简单的“正常”行为和一个例外而已。所谓的正常行为就是future的析构函数直接析构future对象,它既不执行join,也不执行deatch,也不做其他乱七八糟的事情,它唯一做的事情就是析构了future的data member(实际上它还减少了共享状态的引用计数)。

例外指的是同时满足下述3个条件的future:

  1. 该std::future通过std::async创建,并且指向了一个共享状态。
  2. 该task的启动策略为std::launch::async。
  3. 该future是最后一个指向该共享状态的future。

只有满足上述三个条件的情况下future的析构函数才会被阻塞至task完成,也就是相当于隐式join。

std::future的这种特殊的析构行为让我们的程序行为变得不可预测,特别是我们没有办法知道哪个 future会隐式join,哪些又将detach。尽管如此,但是我们知道,凡是从std::async创建的std::future都有可能隐式join,而其他方式创建的std::future对象则不是,比如通过std::packaged_task创建的std::future,其析构就不会隐式join。


总结

  1. 通常情况下,future的析构函数将破坏其data member。
  2. 在特殊情况下future的析构函数类似于隐式join。

37.令std::thread在所有路径上unjoinable

Posted on 2018-07-18 | In Effective Modern C++

joinable与unjoinable

 
每个std::thread对象都处于以下两种状态之一:joinable或unjoinable。joinable std::thread对应于正在运行或可能正在运行的底层异步执行线程。例如,对应于阻塞或等待调度的底层线程的std ::thread是joinable,与已完成运行的底层线程相对应的std::thread对象也可被认为是joinable。

显然,不属于上述情况的std::thread自然为unjoinable,一般而言有四种情况:

  1. 默认构造的std::thread
    此类std::thread没有执行函数,因此不对应于任何底层执行线程。
  2. 已被move的std::thread
    move意味着用于对应当前std::thread的底层执行线程现在已对应于别的std::thread。
  3. 已被joined的std::thread
    在joined之后,std::thread对象不再对应于已完成运行的底层执行线程。
  4. 已被detached的std::thread
    detach意味着切断std::thread对象与其对应的底层执行线程之间的连接。

std::thread的joinability之所以重要,是因为如果调用joinable线程的析构函数,则将导致当前执行程序意外终止。


问题实例

 
假设当前存在一个函数doWork,它以过滤器函数filter、最大值maxVal作为参数。doWork检查是否所有计算条件均已符合,然后将计算结果采用过滤器过滤一次。如果进行过滤非常耗时,并且确定是否满足doWork计算条件也很耗时,那么执行并发似乎是一个很好的选择。理论上我们应当采用任务式并发编程(参见Item35),但此项任务需要设定执行filter的线程的优先级,future无法提供这项操作,因此我们只能使用std::thread。实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
constexpr auto tenMillion = 10000000;
bool doWork(std::function<bool(int)> filter,int maxVal = tenMillion{
std::vector<int> goodVals; // values that satisfy filter
std::thread t([&filter, maxVal, &goodVals]{
for (auto i = 0; i <= maxVal; ++i){
if (filter(i)) goodVals.push_back(i);
}
})
auto nh = t.native_handle(); // use t's native handle to set t's priority
...
if (conditionsAreSatisfied()) {
t.join(); // let t finish
performComputation(goodVals);
return true; // computation was performed
}
return false; // computation was not performed
}

在描述这段代码的问题之前,我们首先声明在C++14中,tenMillion的声明可以更具可读性:
1
constexpr auto tenMillion = 10'000'000; // C++14

此外,我们似乎不应当在线程已经开始运行后设定优先级,但这并不影响本节的讨论,关于如何令线程暂停的讨论在Item39。


问题剖析

 
如果conditionsAreSatisfied()返回true,上述程序一切正常。但如果它返回false或抛出异常,则std::thread对象t在doWork完成时处于joinable状态,这将导致程序意外终止(terminate),因为doWork完成后开始调用t的析构函数。

你可能会认为std::thread析构函数的行为不够合理,但实际上另外两种选择更糟:

  1. 隐式join
    在这种情况下,std::thread的析构函数将等待其底层异步执行线程完成。这听起来很合理,但可能导致难以追查的性能缺陷。举例而言,如果conditionsAreSatisfied()已经返回false,那么doWork等待filiter执行结果是毫无意义的。
  2. 隐式detach
    在这种情况下,std::thread的析构函数将切断std :: thread对象与其底层执行线程之间的连接,底层线程将继续运行。这听起来并不比隐式join合理,但它的破坏性更甚一筹。举例而言,doWork中goodVals是通过引用捕获的局部变量,它会被t中运行的lambda修改(通过push_back)。 那么,假设当lambda异步运行时,conditionsAreSatisfied()返回false,doWork将返回false,并且其内部的局部变量(包括goodVals)将被销毁,因此底层线程上的执行方法将无以为继(确切的说,它将访问以前是goodVals但现在已经已经变成了别的对象的内存),这直接导致了雪崩。

调用一个joinable std::thread的析构函数将引起严重后果,因此标准委员会禁止执行这种操作(一旦执行则触发terminate)。因此作为开发者,我们有责任确保如果使用std::thread对象,它将在所有执行路径保持unjoinable。但是覆盖所有的执行路径并非易事,它包括return、continue、break、goto或异常等能够跳出作用域的情况,这些情况意味着海量的路径。

如果你需要在所有出作用域的地方做某件事,最常见的方法就是将这件事放入到这个局部对象的析构函数中,这项技术被称为RAII(详见Effective C++ Item 13)。std::thread并不是一个RAII类,因为标准委员会拒绝在其析构时执行隐式join或detach,但他们也没想出更好的主意。


RAII thread

 
幸运的是,自己写一个RAII thread并不困难。下述设计将允许调用者指定在销毁ThreadRAII对象时是否应调用join或detach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ThreadRAII {
public:
enum class DtorAction { join, detach };
ThreadRAII(std::thread&& t, DtorAction a):action(a),t(std::move(t)) {}
~ThreadRAII(){
if (t.joinable()) {
if (action == DtorAction::join) {
t.join();
} else {
t.detach();
}
}
}
std::thread& get() { return t; }
private:
DtorAction action;
std::thread t;
};

以下该类的一些关键性介绍:

  1. 构造函数只接受std::thread rvalue,因为我们想将传入的std::thread移动到ThreadRAII对象中(std::thread move-only)。
  2. ThreadRAII提供了一个get成员函数来获取它对应的std::thread对象,这模仿了智能指针的get方法。提供get方法可以避免ThreadRAII实现所有的std::thread的接口。
  3. ThreadRAII析构函数首先检查了std::thread是否是joinable,这一点十分必要,因为在unjoinable的std::thread对象上调用join或detach会导致未定义的行为。

ThreadRAII的实际使用案例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool doWork(std::function<bool(int)> filter,int maxVal = tenMillion){
std::vector<int> goodVals;
ThreadRAII t(
std::thread([&filter, maxVal, &goodVals]{
for (auto i = 0; i <= maxVal; ++i){
if (filter(i)) goodVals.push_back(i); }
}),
ThreadRAII::DtorAction::join
);
auto nh = t.get().native_handle();
…
if (conditionsAreSatisfied()) {
t.get().join();
performComputation(goodVals);
return true;
}
return false;
}

不难看出,我们选择了在ThreadRAII中调用join函数等待线程执行结束,因为上文提到使用detach可能会导致程序crash,难以调试,虽然join可能会导致性能下降,但权衡二者,似乎性能下降还在可接受范围之内。C++11和C++14没有实现线程中断的机制,该机制可以手动实现,但不在本书的讨论范围之内。

Item17中的三五法则解释了编译器不会为ThreadRAII生成移动操作,但ThreadRAII理应支持移动操作,因此我们可以利用default完成对它们的声明与定义:

1
2
3
4
5
6
7
8
9
10
11
12
class ThreadRAII{
public:
enum class DtorAction { join, detach }; // as before
ThreadRAII(std::thread&& t, DtorAction a):action(a), t(std::move(t)) {}
~ThreadRAII(){… // as before}
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
std::thread& get() { return t; } // as before
private: // as before
DtorAction action;
std::thread t;
};


总结

  1. 确保std::thread在所有路径上unjoinable。
  2. thread析构函数中执行join可能会导致性能下降。
  3. thread析构函数中执行deatch可能会导致程序crash。
  4. 将std::thread声明为data member的最后一个成员(因为它一旦初始化就开始运行)。

36.必须异步时请使用std::launch::async

Posted on 2018-07-17 | In Effective Modern C++

前言

 
当开发者决定异步执行某函数或其他可调用对象时,往往会选择调用std::async,但实际上这种做法并没有要求异步执行,当你明确需要使用异步时,你应当使用std::launch::async。


启动策略

 
std::lauch是一个enum class,其中含有两个枚举量,分别代表着不同的异步启动策略。假定当前存在一个函数f被传递至std::async

  1. std::launch::async launch policy意味着函数f必然将运行于另一个线程之上(即异步运行)
  2. std::launch::deferred launch policy意味着f只有在std::async返回的future对象上调用get或wait时才会运行
    换而言之,f的执行被推迟至调用get或wait时。当调用get或wait时,f将同步执行(即调用程序将阻塞,直到f完成运行)。如果一直不调用get或wait,f将永不执行。

默认启动策略

更加让人惊讶地是,std::async的默认启动策略并非是二者中的某一种,而是二者的综合,也就是说下述两种调用其实时等价的:

1
2
auto fut1 = std::async(f); // run f using default launch policy
auto fut2 = std::async(std::launch::async|std::launch::deferred,f);

因此,默认启动策略允许f以异步或同步方式运行。如Item35所述,这种灵活性允许std::async与标准库中线程管理组件承担线程的创建和销毁任务,避免触发oversubscription以及保证负载均衡,因此使用std::async可以保证开发者能够高效完成并发编程。


默认启动策略特性

std::async的默认启动策略还有一些值得一提的因素。假设当前存在一个线程t正在执行以下语句:

1
auto fut = std::async(f); // run f using default launch policy

我们将得出以下结论:

  1. 无法预测f是否将与t同时运行,因为f可能被安排为推迟执行(因为不明确调用get或wait的时机)。
  2. 无法预测f是否会在一个不同于调用fut之get或wait的线程上执行,本例即无法预测f的执行线程是否不同于t。
  3. 无法预测f是否会运行,因为可能无法保证在程序的所有路径上都会调用fut的get或wait。

默认启动策略几乎不能与thread_local变量混用,因为混用意味着f要读或写thread-local storage(TLS) ,但我们并不知道究竟会访问哪一个线程中的变量:

1
2
// TLS for f possibly for independent thread, but possibly for thread invoking get or wait on fut
auto fut = std::async(f);

默认启动策略还会影响使用timeout的wait-based loop,因为在被推迟的task(见Item35)上调用wait_for或wait_until会产生std::launch::deferred。这意味着以下循环看起来似乎会终止,实际上也许将永远运行:

1
2
3
4
5
6
7
8
using namespace std::literals; // for C++14 duration suffixes;see Item 34
void f() {// f sleeps for 1 second,then returns
std::this_thread::sleep_for(1s);
}
auto fut = std::async(f); // run f asynchronously(conceptually)
while (fut.wait_for(100ms) != std::future_status::ready){ // loop until f has finished running...
…// may be always runing
}

如果f与调用std::async的线程同时运行(即启动策略为std::launch::async),那么该程序毫无问题。但如果f推迟运行,那么fut.wait_for将永远返回std::future_status::deferred,因而造成死循环。

这种bug在开发和单元测试阶段很容易被忽视,因为它只有在系统负载过重时才会出现。要修正该问题也很简单:只需通过std::async返回的future对象检查task是否被推迟,如果是则并不执行循环。然而不幸的是,并没有直接方法来检查tsk是否被推迟,因此我们不得不调用一个基于time-out的函数(比如wait_for)。因为我们只是需要明确task状态,而不是真的想等待什么,因此等待参数设为0s即可:

1
2
3
4
5
6
7
8
9
auto fut = std::async(f);
if (fut.wait_for(0s) == std::future_status::deferred){ // deferred...
… // ...use wait or get on fut to call f synchronously
} else { // task isn't deferred
while (fut.wait_for(100ms) != std::future_status::ready) {
… // task is neither deferred nor ready,so do concurrent work until it's ready
}
… // fut is ready
}


默认启动策略使用条件

 
只要满足以下条件,我们就可以放心大胆的采用默认启动策略执行task:

  1. task无需与调用wait或get的线程并行执行
  2. 无需关注存取哪个线程的thread_local变量
  3. 要么能够保证返回future对象的get或者wait方法一定会被调用,或者允许task不被执行
  4. 在使用wait_for和wait_unitl时检测task状态是否为defered

只要上述条件有一个不满足,那我们都应当使用std::lunch::async确保异步执行task:

1
auto fut = std::async(std::launch::async, f); // launch f asynchronously

事实上,如果能有一个函数功能与std::async一样,但是自动采用std::launch::async作为启动策略,会是一个很方便的工具,但实现它也不难,以下为C++11版本:

1
2
3
4
5
6
template<typename F, typename... Ts>
inline
std::future<typename std::result_of<F(Ts...)>::type>
reallyAsync(F&& f, Ts&&... params){
return std::async(std::launch::async,std::forward<F>(f),std::forward<Ts>(params)...);
}

C++14由于返回值类型推衍的原因,实现可以得到进一步的简化:
1
2
3
4
5
template<typename F, typename... Ts>
inline
auto reallyAsync(F&& f, Ts&&... params){
return std::async(std::launch::async,std::forward<F>(f),std::forward<Ts>(params)...);
}


总结

  1. std::async的默认启动策略既同步也异步
  2. 默认启动策略可能会导致TLS访问的不确定性、以及wait_for、wait_until的使用时状态检查
  3. 如果异步执行至关重要,请采用std::launch::async启动策略

35.优先选择任务式并发编程而非线程式并发编程

Posted on 2018-07-17 | In Effective Modern C++

线程式编程与任务式编程

 
如果当前需要异步运行函数doAsyncWork,我们有两种基本操作。第一种利用std::thread创建线程然后在其上运行doAsyncWork,也就是线程式并发编程:

1
2
int doAsyncWork();
std::thread t(doAsyncWork);

又或者你可以将doAsyncWork传递给std::async,也就是所谓的任务式并发编程:
1
auto fut = std::async(doAsyncWork); // "fut" for "future"

在上述调用中,传递给std::async的函数对象(doAsyncWork)被认为是一项任务。

任务式并发编程通常优于线程式并发编程,这一点在上述实例中已经有所体现。doAsyncWork存在一个返回值,我们可能在程序运行期间需要使用其返回值,但线程式并发编程无法直接获取这个值,而任务式并发编程则可以,因为std::async返回的future提供了get。此外,如果doAsyncWork抛出异常,我们也可以通过get访问该异常,而线程式并发编程则将直接崩溃(调用std::terminate)。

我们可以认为,线程式与任务式的最本质区别在于任务式具备更高的抽象层次,从而使得开发者不必关注线程管理细节。


线程特性

在C++并发编程中,“thread”具备3大含义:

  1. Hardware thread
    硬件线程是实际执行计算的线程,当代计算机架构为每个CPU核心提供一个或多个硬件线程。
  2. Software thread(OS thread or system thread)
    软件线程(也称为OS线程或者系统线程)是指那些由操作系统管理,在所有在硬件线程上执行任务的进程和任务。通常可以创建比硬件线程更多的软件线程,因为当软件线程被阻塞时(例如运行于IO以及等待mutex斥或condition variable),可以通过执行其他未阻塞的线程来提高吞吐量。
  3. std::thread
    std::thread是C++进程中的对象,它充当底层软件线程的句柄。一些std::thread对象表示“null”句柄(即不对应于任何软件线程)。造成这种现象的原因大致有四种:处于default construct state(没有需要执行的函数),已被移动至另一个线程(该std::thread对象便代表了底层软件线程的句柄),已经joined(要运行的函数已经完成)以及已被detached(它与其底层软件线程的联系已被切断)。

线程式缺陷

软件线程是受限的资源,如果尝试创建超过系统可以提供的内容,则会抛出std::system_error异常,即使您要运行的函数不能抛出异常也是如此,也就是说即使doAsyncWork是noexcept:

1
int doAsyncWork() noexcept;

如下语句也可能会抛出一个异常:
1
std::thread t(doAsyncWork); // throws if no more threads are available

作风优良的软件必须以某种方式处理这种可能性,但我们应当如何实现?一种方法是在当前线程上运行doAsyncWork,但这可能导致不平衡的负载,并且如果当前线程是GUI线程则又将引起响应性问题。另一个选择是等待一些现有的软件线程执行完毕,然后再次尝试创建一个新的std::thread,但现有的线程可能正在等待doAsyncWork应该执行的操作。

即使你没有将所有线程资源消耗殆尽,也可能会触发oversubscription(即处于ready-to-run的软件线程要多于硬件线程)。当发生这种情况时,线程调度程序(通常是OS的一部分)会对硬件线程上的软件线程进行时间分片。当某个线程的时间片用完时,硬件使用权将交付给另一个线程,完成context switch。context switch增加了系统的整体线程管理开销,并且当硬件线程需要运行的软件线程的上一个时间切片是在另外一个核时,这种切换会变得尤为昂贵。在这种情况下

  1. CPU的缓存通常对新的软件线程帮助较少(只包含零丁有用的数据和指令)
  2. 新线程运行的内容可能会污染该核的缓存,因为老进程可能马上将回归该核继续执行

避免oversubscription十分困难,因为软件与硬件线程的最佳比率取决于软件线程的运行频率,并且该频率是时变而非定常的(例如,当程序从I/O密集区转向计算密集区时)。软件线程与硬件线程的最佳比例还取决于context switch的成本以及软件线程如何有效地使用CPU高速缓存。此外,硬件线程的数量和CPU缓存的具体细节(大小及其相对速度)取决于计算机架构,因此即使调整应用程序以避免oversubscription(同时仍保持硬件繁忙),也无法保证在同一平台下的其他计算机能够流畅运行。


std::async优势

 
上述难以解决的问题最好交给专业人士解决,而std::async就是那个专业人士:

1
2
// onus of thread mgmt is on implementer of the Standard Library
auto fut = std::async(doAsyncWork);

此调用将线程管理职责转移到C++标准库的实现者,因此,我们将再也不用担心线程耗尽而产生异常,因为这根本不会产生一个新线程。这一点十分重要,因为std::async以这种方式启动时(即Item38所说的默认启动方式),并不保证它一定会产生一个新线程。事实上,而当存在oversubscription或者线程耗尽时,它将运行调度器安排执行体(在这个例子中是doAsyncWork)在当前需要doAsyncWork返回结果的线程中运行。
如果你要自己模拟这个功能,这当然能够实现,只是这有可能会导致负载失衡或者GUI系统中的响应问题,并且这些问题不会仅仅因为你使用的是std::async而消失,只是在std::async中使用调度器帮你解决了这个问题。调度器显然比你更加了解当前机器的负载问题,因为它管理所有进程的线程,而非只了解运行你的代码的那个进程。即使使用std::async,GUI程序仍然会存在响应性问题,因为调度器并不明确哪些线程存在较高的响应需求。在这种情况下,我们应当将std::launch::async启动策略传递给std::async,这将确保要运行的函数真正运行于不同的线程之上(参见条款36)。

目前最先进的线程调度器采用system-wide线程池来避免oversubscription,并且它们还会通过work-stealing算法改善CPU核之间的负载平衡。C++标准并不强制使用线程池或work-stealing算法,而且说实话,某些C++11并发规范的技术层令使用它们比我们想象地更难。尽管如此,一些厂商仍然在其标准库实现中使用了这些技术,并且我们有理由相信会有越来越多的厂商加入他们。如果我们采用任务式并发编程,那些这些技术将自动为我们所用,反之,如果你采用std::thread,那你将不得不手动处理线程耗尽、在oversubscription、负载均衡等等问题。


std::thread优势

 
尽管任务式并发编程优势巨大,但我们在以下三种情况还是不得不使用std::thread:

  1. 需要获取底层线程实现的API
    C++并发API通常使用较低级别平台的特定API实现,通常是pthreads或Windows下的Threads。 目前这些API比C++提供的接口更为丰富。(比如C++没有线程优先级或affinities的概念。)std::thread对象通常具备native_handle成员函数以便于开发者访问底层实现API,而std::future没有对应的功能。
  2. 需要针对特定应用完成线程优化
    具体实例可以是当前我们正在开发一款具有已知执行配置文档的服务器软件,该软件将被部署为某机器的唯一进程。
  3. 需要实现C++并发API之外的线程技术
    例如实现一个线程池等等。

总结

  1. std::thread的API中没有直接提供获取执行函数的返回值的方法,如果执行函数中抛出异常,那么程序将立即终止。
  2. 基于线程编程需要手动处理线程耗尽、oversubscription、负载均衡以及平台适应性等问题。
  3. 通过默认方式调用std::async的任务式并发编程不存在以上任何缺点。

C++并发编程

Posted on 2018-07-17 | In Effective Modern C++

C++11的一大成就在于将并发性融入语言和库中,这意味着在C++历史上,程序员第一次可以在所有平台上编写具有标准行为的多线程程序,作为C++开发者,我们应当坚信这只是C++通用并发编程的开始。

标准库中存在两个future模板,它们分别是std::future与std::shared_future。在大多数场合开发者并不需要了解它们的区别,因此本章中提到的future一般都是代指这两种。

34.尽量以lambda取代std::bind

Posted on 2018-07-17 | In Effective Modern C++

前言

 
std::bind是C++98中std::bind1st和std::bind2nd的C++11继承者,但C++11引入的lambda几乎全面优于std::bind,并且C++14中lambda得到了更进一步地加强。


问题实例

 
在Item32中,我们曾经提及std:bind返回一个bind对象。我们可以认为,lambda优于bind的最主要原因在于lambda具备更高的可读性,下文中的实例将证明这一点。
假定当前有一个声音报警函数:

1
2
3
4
using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;
void setAlarm(Time t, Sound s, Duration d);

进一步地,我们假定警报将在设定完成后经过1小时触发,并且持续30s。目前尚未明确的是警报选用何种声音,因此我们可以编写一个lambda来完成设定:
1
2
3
4
auto setSoundL =[](Sound s){
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1),s,seconds(30));
};

C++14支持时间后缀(s、ms、h等等),它们位于命名空间std::literals之中,因此上述代码可以进一步被简化为:
1
2
3
4
5
auto setSoundL =[](Sound s){
using namespace std::chrono;
using namespace std::literals;
setAlarm(steady_clock::now() + 1h,s,30s);
}


有关bind的第一次尝试

我们试图用std::bind完成上述功能:

1
2
3
4
5
6
7
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders; // needed for use of "_1"
auto setSoundB = std::bind(setAlarm,
steady_clock::now() + 1h, // incorrect! see below
_1,
30s);

这段代码的读者只需知道调用setSoundB是将会触发setAlarm的调用。_1的含义是:将setSoundB的第一个参数作为setAlarm的第二个参数。在调用std::bind时并不能明确此参数的类型,因此读者必须查阅setAlarm声明以确认要传递给setSoundB的参数类型。

上述代码并不正确。在lambda中,表达式“steady_clock::now()+ 1h”是setAlarm的参数,调用setAlarm时将对其进行求值(evaluate),因此完成在setAlarm后1小时触发报警。 但在std :: bind调用中,“steady_clock::now()+ 1h”是传递给std::bind的参数,而不是传给setAlarm,这意味着将在调用std::bind时完成表达式求值,也就是在调用std::bind一小时后触发警报。


有关bind的第二次尝试

修复上述问题的关键在于令std::bind明确在调用setAlarm前推迟对表达式的求值,为此我们需要引入第二个bind:

1
auto setSoundB =std::bind(setAlarm,std::bind(std::plus<>(), steady_clock::now(), 1h),_1,30s);

如果熟悉C++98中的std::plus模板的话,你可能会惊讶地发现当前模板并未指定模板参数,即我们撰写的是“std::plus<>”而非 “std::plus<type>”。原因在于C++14中通常可以省略标准运算符模板的模板类型参数,因此不需要在此处提供它。 C+ 11没有提供这样的功能,因此与lambda等效的C++11 std::bind需要写为:
1
2
3
4
5
6
using namespace std::chrono;
using namespace std::placeholders;
auto setSoundB =std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(),steady_clock::now(),hours(1)),
_1,
seconds(30));


引入重载后的问题实例

 
当setAlarm添加了重载函数后,问题再次产生了变化。假定当前我们除了可以设定声音类型外,还可以设定声音大小:

1
2
enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlarm(Time t, Sound s, Duration d, Volume v);

lambda运行得照样很流畅,因为它将直接调用setALarm的三参数版本:
1
2
3
4
auto setSoundL = [](Sound s){
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h,s,30s); // calls 3-arg version of setAlarm
};

但bind则无法通过编译,原因在于它并不能确定调用哪个setAlarm:
1
2
// error! which setAlarm?
auto setSoundB =std::bind(setAlarm,std::bind(std::plus<>(), steady_clock::now(), 1h),_1,30s);

为了解决这一问题,必须将setAlarm转换为正确的函数指针类型:
1
2
3
4
5
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = std::bind(static_cast<SetAlarm3ParamType>(setAlarm),
std::bind(std::plus<>(),steady_clock::now(),1h),
_1,
30s)

但这会带来lambda和std::bind之间的另一大区别。在setSoundL的operator()(即lambda所生成的closure class的operator())中,对setAlarm的调用是一个普通的函数调用,因此可以通过编译器完成内联:
1
setSoundL(Sound::Siren); // body of setAlarm may well be inlined here

但在std::bind的调用中,编译器将函数指针传递给setAlarm,这意味着在setSoundB的operator()内(即bind对象的operator()),对setAlarm的调用通过函数指针完成。编译器不太可能通过函数指针内联函数调用,这意味着std::bind的内联程度往往低于lambda版本:
1
setSoundB(Sound::Siren); // body of setAlarm is less likely to be inlined here

因此,使用lambda在效率上亦可能优于bind。


问题实例二

 
setAlarm示例仅涉及一个简单的函数调用,lambda在复杂事物的处理中将更加大放异彩。考虑如下所示的C++14 lambda,它返回其参数是否在最小值(lowVal)和最大值(highVal)之间,其中lowVal和highVal是局部变量:

1
auto betweenL =[lowVal, highVal](const auto& val){ return lowVal <= val && val <= highVal; };

C++11不支持在lambda中使用auto,因此其形式如下:
1
auto betweenL = [lowVal, highVal](int val){ return lowVal <= val && val <= highVal; };

std::bind也能完成同样的工作,只是过于晦涩:
1
2
3
4
using namespace std::placeholders;
auto betweenB =std::bind(std::logical_and<>(),// C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));

C++11所需要的代码量更多:
1
2
3
auto betweenB =std::bind(std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, _1),
std::bind(std::less_equal<int>(), _1, highVal));

显然,lambda版本代码量更少,且具有更高的可读性与可维护性。


std::bind的些许晦涩之处

 
前文中出现的_1可能会令某些从未接触过std::bind的读者大感神奇,但std::bind的晦涩之处不止如此。假定当前我们有一个函数来创建Widget的压缩副本:

1
2
enum class CompLevel { Low, Normal, High }; // compression level
Widget compress(const Widget& w, CompLevel lev); // make compressed copy of w

更进一步地,我们希望创建一个函数对象,它允许我们指定压缩品质,std::bind有实现如下:
1
2
3
Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);

显然,当我们将w传递给std::bind时,必须将其存储于bind对象内部以便于后期的压缩函数调用。但本次实现是以refernece还是value方式存储?若以引用存储,w在调用bind对象之前发生的改变将反应于compress之中,否则不然。答案是std::bind按值存储,std::bind表达式并不表明其存储方式(这意味着你只能硬生生记下来),这一点与lambda的显式声明并不相同:
1
2
// w is captured by value; lev is passed by value
auto compressRateL =[w](CompLevel lev){ return compress(w, lev); };

以何种方式将参数传递给lambda也十分明确:
1
compressRateL(CompLevel::High); // arg is passed by value

但std::bind则不然:
1
compressRateB(CompLevel::High); // how is arg passed?

同样,唯一知道传入参数方式的方法是记住std::bind的工作原理。(答案是传递给bind对象的所有参数都是by-reference,因为这些对象的operator()使用完美转发。)


std::bind的可用之处

 
在C++14中std::bind全面落后于lambda,但在C++11中它存在两点用武之地:

  1. 移动捕获
    (详见Item32)
  2. 多态函数对象
    因为bind对象的operator()使用完美转发,所以它可以接受任何类型的参数,因此当你想要讲一个对象绑定至模板函数operator()将非常有用,例如以下实例:
    1
    2
    3
    4
    5
    6
    class PolyWidget {
    public:
    template<typename T>
    void operator()(const T& param);
    …
    };
    std::bind可以用如下形式绑定一个PolyWidget:
    1
    2
    PolyWidget pw;
    auto boundPW = std::bind(pw, _1);
    boundPW则可以通过多种参数完成调用:
    1
    2
    3
    boundPW(1930); // pass int to PolyWidget::operator()
    boundPW(nullptr); // pass nullptr to PolyWidget::operator()
    boundPW("Rosebud"); // pass string literal to PolyWidget::operator()
    C++11无法模拟出上述情形,C++14可以通过auto来解决:
    1
    auto boundPW = [pw](const auto& param){ pw(param); };

总结

  1. lambda在可读性与效率上均优于std::bind。
  2. C++11中可以利用std::bind实现移动捕获与将对象绑定至模板化operator()。
<i class="fa fa-angle-left"></i>1…567…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4