首页 Effective Modern C++ 笔记
文章
取消

Effective Modern C++ 笔记

Effective Modern C++ 笔记

条款18需要显式所有权的资源管理时,用std::unique_ptr

条款26 避免重载万能引用

2023.2.21:直接看模板的6.2

第一次看书给我看乐了。核心就一句话。根据C++的重载决议规则,万能引用版本总会被优先匹配。万能引用很jb贪。。它们会在具现过程中,和几乎任何实参型别都会产生精确匹配。而且在重载过程当中,万能引用模板还会和构造函数拷贝构造函数竞争。例子不写了,看书吧。反正别重载万能引用就行。

要点:

  • 把万能引用作为重载候选型别,几乎总会让该重载版本在始料未及的情况下被调用到
  • (完美转发)构造函数(使用了万能引用)的问题尤其严重,因为对于非常量的左值型别而言,它们一般都会形成相对于复制构造函数的更佳匹配,并且它们还会劫持子类中对父类的复制和移动构造函数的调用

条款27:26的解决方案

1. 使用常量左值引用做为形参(const &)

常量左值引用可以接受任意类型的参数。(常量左值,左值,常量右值,右值)。虽然效率低一些,但是可以正确使用。

2. 传值

有些人忽略一点。以值传递是可以接受右值的

1
2
3
4
5
6
7
8
9
void num(int a){
    cout << a << endl;
}
int main(){
    int digit = 5;
    num(digit);	//	OK 
    num(800); 	//	OK
    return 0;
}

所以:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
public:
    explicit Person(std::string n) //值传递
    : name(std::move(n)) {}

    explicit Person(int idx)
    : name(nameFromIdx(idx)) {}
    ...
private:
    std::string name;
};

没有效率损失的原因:如果实参是左值,那么实参到形参是一次复制,形参到name是一次移动,相比普适引用只多了一次移动;如果实参是右值,那么实参到形参是一次移动,形参到name还是一次移动,相比普适引用还是只多一次移动,可以认为没有效率损失。

3.使用标签。

2023.2.21:直接看模板的标记派发。

这里很有意思。侯捷老师讲STL的时候提到过,当时没有理解。现在有点理解了。

我们重新实现 logAndAdd把它委托给另外两个函数,一个接受整型值,另一个接受其他所有型别。而 logAndAdd 本身则接受所有型别的实参,无论整型和非整型都来者不拒。

改动前的原始版本:

1
2
3
4
5
6
std::multiset<std::string> names;
template <typename T>
void logAndAdd(T&& name) {
    names.emplace(std::forward<T>(name)); //具体代码细节无须在意
    //...
}

接近实现正确的版本:

1
2
3
4
5
6
template <typename T>
void logAndAdd(T&& name) {
    logAndAddImpl(std::forward<T>(name), 
                  std::is_integral<T>()//检查T的类型是否为整型
    );
}

上面的问题是:当实参是左值时,T会被推导为左值引用,即如果实参类型是int,那么T就是int&,(杂记的完美转发推导有写)std::is_integral<T>()就会返回false(此函数判断是否为整型。但是所有的引用型别都不是整型)。这里我们需要把T可能的引用性去掉

1
2
3
4
5
6
7
template <typename T>
void logAndAdd(T&& name) {
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>() //检查T的类型是否为整型
    );
}

然后logAndAddImpl提供两个特化版本:

1
2
3
4
5
6
7
8
9
template <typename T>
void logAndAddImpl(T&& name, std::false_type) { //如果不是整型
    names.emplace(std::forward<T>(name)); //具体代码细节无须在意
}

template <typename T>
void logAndAddImpl(T&& name, std::true_type) {	//如果是整型
    logAndAdd(nameFromIdx(idx)); //具体代码细节无须在意
}

为什么用std::true_type/std::false_type而不用true/false?前者是编译期值,后者是运行时值。

注意这里我们都没有给logAndAddImpl的第二个参数起名字,说明它就是一个Tag。这种方法常用于模板元编程。它们在运行期不起任何作用。

条款35 优先选用基于任务(std::async)而非基于线程(std::thread)的程序设计。

关于这一点其实我也困惑了比较久,尤其是基于std::threadstd::async这样的高级函数,和POSIX系列的底层接口在多线程中的区别。

一般来说,在使用现代C++的高级库的时候,异步操作可以选用std::threadstd::async

基于任务的方法通常比基于线程实现的对应版本要好主要原因是async可以让你获取到异步执行的返回值。因为我们要有一个future对象做为句柄。而thread则不会给你机会直接返回一个返回值。

线程在C++软件的世界里有三种含义:

  • 硬件线程是实际执行计算的线程。现代计算机体系结构会为每个CPU内核提供一个或多个硬件线程。
  • 软件线程(又称操作系统线程或系统线程)是操作系统用以实施跨进程的管理,以及进行硬件线程调度的线程。通常,能够创建的软件线程会比硬件线程要多,因为当一个软件线程阻塞了(例如,阻塞在I/O操作上,或者需要等待互斥量或条件变量等),运行另外的非阻塞线程能够提升吞吐率。
  • std::thread是C++进程里的对象,用作底层软件线程的句柄。有些std::thread对象表示为“null”句柄,对应于“无软件线程”,可能的原因有:
    • 它们处于默认构造状态(因此没有待执行的函数)
    • 被移动了(作为移动目的的std::thread对象成为了底层线程的句柄)
    • 被联结了(join。待运行的函数已运行结束),
    • 被分离了(detachstd::thread对象与其底层软件线程的连接被切断了)。

那么什么时候使用std::thread更好呢?

  • 需要访问底层线程实现的API。
    • std::threadnative_handle函数提供了一个返回底层线程句柄的方法。
  • 需要且有能力优化线程用法
  • 需要实现超越C++并发API的线程技术。比如细化线程池。

注意到我们必须时刻关注线程运行的数量。所以async的默认启动方式是std::launch::async | std::launch::deferred。也就是到底是真正的异步执行还是推迟执行是交给系统去选择的。这是下一条的讨论重点

条款36,如果异步是必要的,则指定std::launch::async

我们在杂记3中已经详细讨论过了async的启动方式。我感觉大多数情况下,只要使用了async,应该都会使用异步方式启动。但是为了避免意外情况,在这里详细讨论一下,使用默认方式启动可能会遇到的问题。

比如我们有

1
auto fut1 = std::async(f);
  • 无法预知f是否会和调用线程并发运行,因为f可能会被调度为推迟运行。
  • 无法预知f是否运行在与调用futgetwait函数的线程不同的某线程之上。
  • f是否会运行这件起码的事情都是无法预知的,这是因为无法保证在程序的每条路径上,futgetwait都会得到调用。

尤其是针对有thread_local变量的情况下,我们无法确认f到底是使用调用线程的thread_local变量还是新线程的。

基于future 的 wait_untill /wait_for 的循环以超时为条件的情况下可能导致永远无法退出循环

假设我们有如下代码

1
2
3
4
5
6
7
8
void f(){
    //一些操作
}
auto fut = async(f); //默认启动条件。表面上的异步运行

while(fut.wait_for(100ms) != std::future_status::ready){ //注意这里
    //其他操作
}
  • 假设如果f和调用线程是并发执行的,也就是async真的异步启动了,那么这个操作没什么问题。
  • 但是假设f并没有被async异步启动,而是采用了推迟执行。由于我们从未对fut调用过getwait,则f操作永远不会被执行。该future对象的状态将永远是std::launch::deferred。所以这个while永远都不会退出。

修正技巧:调用wait_untill 或 wait_for 并检查返回值

比如上面的代码可以修改为这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void f(){
    //一些操作
}
auto fut = async(f); //默认启动条件。表面上的异步运行

if(fut.wait_for(0s) == std::future_status::deferred){ //实际上我们不需要等待任何事情,0s即可。然后查看是否被推迟
    fut.get();//如果被推迟,就显式的调用get或wait
}
else{
    while(fut.wait_for(100ms) != std::future_status::ready){ //如果未被推迟,确为异步执行。则正确执行。
    	//其他操作
	}
}

  • 调用wait_for中,实际上我们不需要等待任何事情,所以参数为0s即可。然后查看是否被推迟。如果被推迟,就显式的调用getwait

条款37 使thread对象在所有路径皆不可联结 – 也就是让thread对象在离开其作用域范围之前被join或detach掉。

换句话说就是:thread对象在离开其作用域的时候,必须确保它的状态不是joinable的。

非joinable的thread对象包括:

  • 默认构造的(本来就无效)
  • 已被移动的(所有权被转移)
  • 已被join的(废话)
  • 已被detach的(thread对象已和底层执行线程分离)

我们在并发笔记中提到了,如果一个thread对象在离开作用域(调用析构函数)的时候,仍然是joinable的状态,则会直接调用std::terminate

解决这个问题的方式是使用RAII管理线程对象。我们在并发笔记中写到了。此处不赘述。

条款38 对变化多端的线程句柄析构函数行为保持关注

这一条主要讲的是future的共享状态到底是什么,和future对象的析构函数在什么时候会被阻塞。请查看杂记3。

条款39 考虑针对一次性事件通信使用以void为模板型别实参的future对象

这话听着贼诡异。前半部分讲的是条件变量的虚假唤醒和唤醒丢失。这一部分在项目相关里面写过了。

后半部分是着重强调了一下一次性事件的通信我们知道futurepromise所保有的共享状态的状态是不可逆的。也就是一旦被设置好了值,futurepromise是不可复用的。future只能get一次,promise只能set一次。它们之间的通信通道是一次性的。所以这就是它们和条件变量的最大区别。这种future通信方式有一个缺点,就是需要使用堆内存,可能造成性能影响。

但是如果有一些事情刚好是只需要一次性通信的,比如创建线程后对线程暂停,进行亲和性或优先级的设置,这个时候它们就非常有用。因为此时搭配条件变量和互斥量的开销较大。

QQ截图20230224235914

这个例子就是我们所谓的创建线程中对线程执行进行暂停。直到我们set_value之前,react不会被执行。所以我们可以在这个线程执行react之前对它进行一些设置。注意,此处写成普通函数依旧可以。

  • thread构造函数中会立刻新建线程,在新线程中对可调用对象进行”启动”然后立刻返回。(不要纠结于为什么创建新线程的函数可以立刻返回,如果非常纠结的话看深入理解linux内核的第三章。大概可以理解为创建线程用的clone函数只负责创建,创建成功后会立刻返回,函数本身不会执行具体的任务。只是把任务当做参数传入然后把线程放入调度队列。然后交给调度器来决定什么时候新线程开始执行。比如在上面的例子中,整个lambda是参数传入给新的线程。当clone返回后,如果cpu决定执行这个线程了,就开始执行lambda内的函数。我们无法保证也没有必要保证set_value到底会在wait前还是wait后被执行。因为wait始终会等待值被设定好。和我们的条件变量为什么需要判断信号丢失的原理是一致的。

QQ图片20230225013643

回到我们的代码中,上面的代码有一个问题。因为我们在条款37中提到了尽可能的使用RAII管理线程对象,所以我们的新代码可能会像是这个样子:

QQ截图20230225013828

这段代码有个非常严重的问题。所以我们剖析一下:

  1. 假如红框部分抛出了异常,则p.set_value()永远都不会被执行。
  2. 如果它不执行,则ThreadRAII保有的线程对象会在新的线程中被卡在p.get_future().wait()这一行。新线程无法执行完毕这个任务函数,也就无法返回。
  3. 同时,在主线程中,我们如果要离开detect函数,则ThreadRAII对象必须析构。
  4. ThreadRAII的析构函数会调动join函数。如果任务函数不返回,join会一直等待任务函数返回。所以一旦红框抛出异常,则析构函数永远都不会执行完成。整个函数就会失去响应。
本文由作者按照 CC BY 4.0 进行授权