如同大多数C++标准库一样
线程在std::thread对象创建(为线程指定任务)启动
最简单的任务,通常是无参数无返回(void-returning)的函数,这种函数在其所属线程上运行,直到函数执行完毕,线程也就结束了。
std::thread 可以用可调用(callable)类型构造,将带有函数调用符类型
的实例传入 std::thread 类中,替换默认的构造函数。
启动了线程,你需要明确是要等待线程结束(加入式joined),还是让其自主运行(分
离式——detached)。如果 std::thread 对象销毁之前还没有做出决定,程序就会终止
使用detach()会让线程在后台运行,这就意味着主线程不能与之产生直接交互。也就是说,不会等待这个线程结束;
如果线程分离,那么就不可能有 std::thread 对象能引用它,分离线程
的确在后台运行,所以分离线程不能被加入。不过C++运行库保证,当线程退出时,相关资源的能够正确回收,后台线程的归属和控制C++运行库都会处理。
假设要写一个在后台启动线程的函数,想通过新线程返回的所有权去调用这个函数,而不是
等待线程结束再去调用;或完全与之相反的想法:创建一个线程,并在函数中转移所有权,
都必须要等待线程结束。总之,新线程的所有权都需要转移。
线程的所有权可以在 std::thread 实例中移动,下面将展示一个例子。
std::thread 支持移动,就意味着线程的所有权可以在函数外进行转移,就如下面程序一样。
这个函数将返回能同时并发在一个程序中的线程数量。
例如,多核系统中,返回值就可以能是CPU核芯的数量。
返回值也仅仅是一个提示,当系统信息无法获取时,函数也会返回0。但是,这也无
法掩盖这个函数对启动线程数量的帮助。
//将100个任务分片,分成4片
// 当启动了所有的子线程对数据进行计算,本线程就对数据的最后一块进行计算
线程标识类型是 std::thread::id ,可以通过两种方式进行检索。
std::thread::id 实例常用作检测线程是否需要进行一些操作,比如:当用线程来分割一项工
主线程可能要做一些与其他线程不同的工作。这种情况下,启动其他线程
就是算法核心部分(所有线程都一样的),每个线程都要检查一下,其拥有的线程ID是否与初始线程的ID相同。
讨论了C++标准库中基本的线程管理方式:启动线程,等待结束和不等待结束(因为需要它们运行在后台)。
并了解应该如何在线程启动前,向线程函数中传递参数,如何转移线程的
所有权,如何使用线程组来分割任务。
最后使用线程标识来确定关联数据,以及特殊线程的特殊解决方案
当线程在访问共享数据的时候,必须定一些规矩,用来限定线程可访问的数据。
还有,一个线程更新了共享数据,需要对其他线程进行通知。
从易用性的角度,同一进程中的多个线程进行数据共享,有利有弊。
错误的共享数据使用是产生并发bug的一个主要原因。
当涉及到共享数据时,问题很可能是因为共享数据修改所导致。
如果共享数据是只读的,那么只读操作不会影响到数据,更不会涉及对数据的修改,所以所有线程都会获得同样的数据。
但是,当一个或多个线程要修改共享数据时,就会产生很多麻烦。这种情况下,就必须
小心谨慎,才能确保一切所有线程都工作正常
并发中竞争条件的形成,取决于一个以上线程的相对执行顺序,每个线程都抢着完成自己的任务。大多数情况下,即使改变执行顺序,也是良性竞争,其结果可以接受。
例如,有两个线程同时向一个处理队列中添加任务,因为系统提供的变量保持不变,所以谁先谁后都不会有什么影响。当变量遭到破坏时,才会产生条件竞争。
并发中对数据的条件竞争通常表示为“恶性”(problematic)条件竞争,我们对不产生问题的良性条件竞争不感兴趣。
C++标准中也定义了数据竞争(data race)这个术语,一种特殊的条件竞争:并发的
去修改一个独立对象,数据竞争是(可怕的)定义行为(undefine behavior)的起
这里提供一些方法来解决恶性条件竞争,最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态。
从其他访问线程的角度来看,修改不是已经完成了,就是还没开始。
另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分
割的变化,也就是保证每个不变量保持稳定的状,这就是无锁编程
另一种处理条件竞争的方式是,使用事务(transacting)的方式去处理数据结构的更新,这里的"处理"就如同对数据库进行更新一样。
所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交。
当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory
(STM))。理论研究中,这是一个很热门的研究领域。这个概念将不会在本书中再进行介绍,
因为在C++中没有对STM进行直接支持。
保护共享数据结构的最基本的方式,是使用C++标准库提供的互斥量(mutex)。
当程序中有共享数据,肯定不想让其陷入条件竞争,或是不变量被破坏。
那么,将所有访问共享数据结构的代码都标记为互斥岂不是更好?这样任何一个线程在执行这些代码时,其他任何线程试图访问共享数据结构,就必须等到那一段代码执行结束。
于是,一个线程就不可能会看到被破坏的不变量,除非它本身就是修改共享数据的线程。
当访问共享数据前,使用互斥量将相关数据锁住,再当访问结束后,再将数据解锁。线程库需要保证,当一个线程使用特定互斥量锁住共享数据时,其他的线程想要访问锁住的数据,
都必须等到之前那个线程对数据进行解锁后,才能进行访问。这就保证了所有线程能看到共享数据,而不破坏不变量。
互斥量是C++中一种最通用的数据保护机制,但它不是“银蛋”;精心组织代码来保护正确的数据,并在接口内部避免竞争条件是非常重要的。但互斥量自身也有问
题,也会造成死锁,或是对数据保护的太多(或太少)。
C++中通过实例化 srd::mutex 创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。
不推荐实践中直接去调用成员函数,因为调用成员函数就意味着,必须记住在每个函数出口都要去调用unlock(),也包括异常的情况。
,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。
//两个线程并行访问一个变量
RAII语法实现自动解锁
//两个线程并行访问一个变量
用互斥量来保护数据,并不是仅仅在每一个成员函数中都加入一个 std::lock_guard 对象那么简单。
一个迷失的指针或引用,将会让这种保护形同虚设。
函数可能没在互斥量保护的区域内,存储着指针或者引用,这样就很危险。
更危险的是:将保护数据作为一个运行时参数.
用户提供的函数func①,就意味着foo能够绕过保护机制将函数 malicious_function 传递进去
这段代码的问题在于,它根本没有做到保护:只是将所有可访问的数据结构代码标记为互斥。
因为使用了互斥量或其他机制保护了共享数据,就不必再为条件竞争所担忧吗?并不是这样,你依旧需要确定特定的数据受到了保护。
回想之前双链表的例子,为了能让线程安全地删除一个节点,需要确保防止对这三个节点(待删除的节点及其前后相邻的节点)的并发访问。
如果只对指向每个节点的指针进行访问保护,那就和没有使用互斥量一样,条件竞争仍会发生——整个数据结构和整个删除操作需要保护,但指针不需要保护。
这种情况下最简单的解决方案就是使用互斥量来保护整个链表,尽管对链表的个别操作是安全的,但不意味着你就能走出困境;即使在一个很简单的接口中,依旧可能遇到条件竞争
例如,构建一个类似于 std::stack 结构的栈除了构造函数和swap()以外,需要对 std::stack 提供五个操作:push()一个新元素进栈,pop()一个元素出栈,top()查看栈顶元素,empty()判断栈是否是空栈,size()了解栈中有多少个元素。
即使修改了top(),使其返回一个拷贝而非引用,对内部数据使用一个互斥量进行保护,不过这个接口仍存在条件竞争。
这个问题不仅存在于基于互斥量实现的接口中,在无锁实现的接口中,条件竞争依旧会产生。
这是接口的问题,与其实现方式无关。
一个给定操作需要两个或两个以上的互斥量时,另一个潜在的问题将出现:死锁(deadlock)。
与条件竞争完全相反——不同于两个线程会互相等待,从而什么都没做。
但线程有对锁的竞争:一对线程需要对他们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个解锁。
这样没有线程能工作,因为他们都在等待对方释放互斥量。这种情况就是死锁,它的最大问题就是由两个或两个以上的互斥量来锁定一个操作。
避免死锁的一般建议,就是让两个互斥量总以相同的顺序上锁:总在互斥量B之前锁住互斥量A,就永远不会死锁。某些情况下是可以这样用,因为不同的互斥量用于不同的地方。
不过,事情没那么简单,比如:当有多个互斥量保护同一个类的独立实例时,一个操作对同一个类的两个不同实例进行数据的交换操作,为了保证数据交换操作的正确性,就要避免数据被并发修改,并确保每个实例上的互斥量都能锁住自己要保护的区域。
不过,选择一个固定的顺序(例如,实例提供的第一互斥量作为第一个参数,提供的第二个互斥量为第二个参数),可能会适得其反:在参数交换了之后,两个线程试图在相同的两个实例间进行数据交换时,程序又死锁了!
std::lock ——可以一次性锁住多个(两个以上)的互斥量,并且没有副作用(死锁风险)
互斥量。提供 std::adopt_lock 参数除了表示 std::lock_guard 对象已经上锁外,还表示现成的锁,而非尝试创建新的锁。
这样,就能保证在大多数情况下,函数退出时互斥量能被正确的解锁(保护操作可能会抛出一个异常),也允许使用一个简单的“return”作为返回。还有,需要注意的是,当使用 std::lock 去锁lhs.m或rhs.m时,可能会抛出异常;这种情况下,异常会传播到 std::lock 之外。
当 std::lock 成功的获取一个互斥量上的锁,并且当其尝试从另一个互斥量上再获取锁时,就会有异常抛出,第一个锁也会随着异常的产生而自动释放,所以 std::lock 要么将两个锁都锁住,要不一个都不锁。
虽然锁是产生死锁的一般原因,但也不排除死锁出现在其他地方。
无锁的情况下,仅需要每个 std::thread 对象调用join(),两个线程就能产生死锁。这种情况下,没有线程可以继续运行,因为他们正在互相等待。这种情况很常见,一个线程会等待另一个线程,其他线程同时也会等待第一个线程结束,所以三个或更多线程的互相等待也会发生死锁。
以下提供一些的指导建议,如何识别死锁,并消除其他线程的等待。
避免嵌套锁第一个建议往往是最简单的:
如果能坚持这个建议,因为每个线程只持有一个锁,锁上就不会产生死锁。即使互斥锁造成死锁的最常见原因,也可能会在其他方面受到死锁的困扰(比如:线程间的互相等待)。
当你需要获取多个锁,使用一个 std::lock 来做这件事(对获取锁的操作上锁),避免产生死锁。
使用固定顺序获取锁当硬性条件要求你获取两个以上(包括两个)的锁,并且不能使用 std::lock 单独操作来获取它们;那么最好在每个线程上,用固定的顺序获取它们获取它们(锁)。
获取两个互斥量时,避免死锁的方法:关键是如何在线程之间,以一致性的顺序获取锁。一些情况下,这种方式相对简单。
首先,就像你能将 std::adopt_lock 作为第二个参数传入到构造函数,对互斥所进行管理,你也可以把 std::defer_lock 作为第二个参数传递进去,为了表明互斥量在结构上应该保持解锁状态。
代码长度相同,且几乎等价,唯一不同的就是: std::unique_lock 会占用比较多的空间,并且比 std::lock_guard 运行的稍慢一些。保证灵活性是要付出代价的,这个代价就允许 std::unique_lock 实例不携带互斥量:该信息已被存储,且已被更新。
新创建的 unique_lock 对象获得了由 x 所管理的 Mutex 对象的所有权(包括当前 Mutex 的状态)。调用 move 构造之后, x 对象如同通过默认构造函数所创建的,就不再管理任何 Mutex 对象了。
移动情况是锁的所有权需要从一个域转到另一个
如果被赋值的对象之前已经获得了它所管理的 Mutex 对象的锁,则在移动赋值(move assignment)之前会调用 unlock 函数释放它所占有的锁。
调用移动赋值(move assignment)之后, A对象如同通过默认构造函数所创建的,也就不再管理任何 Mutex 对象了
Mutex 对象的指针,并释放所有权)
上锁操作,调用它所管理的 Mutex 对象的 lock 函数。如果在调用 Mutex 对象的 lock 函数时该 Mutex 对象已被另一线程锁住,则当前线程会被阻塞,直到它获得了锁。
该函数返回时,当前的 unique_lock 对象便拥有了它所管理的 Mutex 对象的锁。如果上锁操作失败,则抛出 system_error 异常。
上锁操作,调用它所管理的 Mutex 对象的 try_lock 函数,如果上锁成功,则返回 true,否则返回 false。
上锁操作,调用它所管理的 Mutex 对象的 try_lock_for 函数,如果上锁成功,则返回 true,否则返回 false。
当你不仅想要保护数据,还想对单独的线程进行同步。例如,在第一个线程完成前,可能需要等待另一个线程执行完成。
通常情况下,线程会等待一个特定事件的发生,或者等待某一条件达成(为true)。这可能需要定期检查“任务完成”标识,或将类似的东西放到共享数据中,但这与理想情况还是差很多。
像这种情况就需要在线程中进行同步,C++标准库提供了一些工具可用于同步操作,形式上表现为
当一个线程等待另一个线程完成任务时,它会有很多选择
一、它可以持续的检查共享数据标志(用于做保护工作的互斥量),直
到另一线程完成工作时对这个标志进行重设。
不过,就是一种浪费:线程消耗宝贵的执行时间持续的检查对应标志,并且当互斥量被等待线程上锁后,其他线程就没有办法获取锁,这样线程就会持续等待。因为以上方式对等待线程限制资源,并且在完成时阻碍对标识的设置。
这个实现就进步很多,因为当线程休眠时,线程没有浪费执行时间,但是很难确定正确的休
眠时间。太短的休眠和没有休眠一样,都会浪费执行时间;太长的休眠时间,可能会让任务
等待线程醒来。休眠时间过长是很少见的情况,因为这会直接影响到程序的行为,当在高节
奏游戏(fast-paced game)中,它意味着丢帧,或在一个实时应用中超越了一个时间片。
三、选择(也是优先的选择)是,使用C++标准库提供的工具去等待事件的发生。
通过另一线程触发等待事件的机制是最基本的唤醒方式(例如:流水线上存在额外的任务时),这种机制就称为“条件变量”(condition variable)。
从概念上来说,一个条件变量会与多个事件或其他条件相关,并且一个或多个线程会等待条件的达成。
当某些线程被终止时,为了唤醒等待线程(允许等待线程继续执行)终止的线程将会向等待着的线程广播“条件达成”的信息。
C++标准库对条件变量有两套实
两者都需要与一个互斥量一起才能工作(互斥量是为了同步);前者仅限于std::mutex 一起工作,而后者可以和任何满足最低标准的互斥量一起工作,从而加上了_any的后缀。
所以,如何使用 std::condition_variable 去处理之前提到的情况——当有数据需要处理时,
如何唤醒休眠中的线程对其进行处理?以下清单展示了一种使用条件变量做唤醒的方式。
// 线程被唤醒, 继续往下执行打印线程编号id.
当前线程调用 wait() 后将被阻塞(此时当前线程应该获得了锁(mutex),不妨设获得锁 lck),直到另外某个线程调用 notify_* 唤醒了当前线程。
在线程被阻塞时,该函数会自动调用 lck.unlock() 释放锁
,使得其他被阻塞在锁竞争上的线程得以继续执行。
另外,一旦当前线程获得通知(notified,通常是另外某个线程调用 notify_* 唤醒了当前线程),wait() 函数也是自动调用 lck.lock(),使得 lck 的状态和 wait 函数被调用时相同。
在第二种情况下(即设置了 Predicate),只有当 pred 条件为 false 时调用 wait() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 pred 为 true 时才会被解除阻塞.
内容同步发表在公众号文章 : ,欢迎关注 : )
在引入之前,我们首先再回顾一下 string
这个也算是计算机里的基本思想了。不同于 eager copy 的每次拷贝都会复制,此种实现方式为写时复制,即 copy-on-write。只有在某个 string 要对共享对象进行修改时,才会真正执行拷贝。
由于存在共享机制,所以需要一个std::atomic<size_t>
,代表被多少对象共享。
Small String Optimization. 基于字符串大多数比较短的特点,利用 string 对象本身的栈空间来存储短字符串。而当字符串长度大于某个临界值时,则使用 eager copy 的方式。
SSO 下,string 的数据结构会稍微复杂点,使用 union 来区分短字符串和长字符串的场景:
这种数据结构的实现下,SSO 的阈值一般是 15 字节。folly 的 fbstring 在 SSO 场景下,数据结构做了一些优化,可以存储 23 个字节,后面会提到。
find()
使用简化版的。在查找成功的情况下,相对于string::find()
有 30 倍的性能提升。在查找失败的情况下也有 1.5 倍的性能提升。
但是这里有一个问题是:SSO 情况下的 size 和 capacity 存在哪里了?
字符串的 small/medium/large 类型对外部透明,而且针对字符串的各种操作例如 copy、shrink、reserve、赋值等等,三种类型的处理方式都不一样,所以,我们需要在上面的数据结构中做些“手脚”,来区分不同的字符串类型。
因为只有三种类型,所以只需要 2 个 bit 就能够区分。相关的数据结构为:
kIsLittleEndian 为判断当前平台的大小端,大端和小端的存储方式不同。
category 与 size 共同存放在 small_的最后一个字节中(size 最大为 23,所以可以存下),考虑到大小端,所以有移位操作,这主要是为了让 category()的判断更简单,后面再详细分析。具体代码在 setSmallSize 中:
category()
为最重要的函数之一,作用是获取字符串的类型:
bytes_定义在 union 中,从注释可以看出来,是为了配合 lastChar 更加方便的取该结构最后一个字节。
配合上面三种类型字符串的存储,可以很容易理解这一行代码。
对着上面的每种情况下字符串的存储的图,应该很好理解,这里不细说了。
首先 fbstring_core 的构造函数中,根据字符串的长度,调用 3 种不同类型的初始化函数:
folly 会通过 canNallocx 函数检测是否使用 jemalloc,如果是,会使用 jemalloc 来提高内存分配的性能。关于 jemalloc 我也不是很熟悉,感兴趣的可以查查,有很多资料。
fbstring 提供了一个特殊的构造函数,让 fbstring_core 接管应用程序自己分配的内存。
可以看出这里没有拷贝字符串的过程,而是直接接管了上游传递过来的指针指向的内存。但是,正如注释说的,这里直接使用了 medium strings 的存储方式。
同初始化,也是根据不同的字符串类型,调用不同的函数:
正如注释中所说,虽然 small strings 的情况下,字符串存储在 small中,但是我们只需要把 ml直接赋值即可,因为在一个 union 中。
逻辑也很清晰:先对引用计数减 1,如果本身就只有 1 个引用,那么直接 free 掉整个 RefCounted。
最重要的一点,也是 large strings 独有的,就是 COW. 任何针对字符串写的操作,都会触发 COW,包括前面举过的[]操作,例如:
所以,当字符串用到[]、at 且不需要写操作时,最好用 const-qualifer.
我们拿 folly 自带的测试一下:
从注释和代码看为什么函数起名叫smartRealloc :
给编译器提供分支预测信息。原型为:
表达式的返回值为 exp 的值,跟 c 无关。 我们预期 exp 的值是 c。例如下面的例子,我们预期 x 的值是 0,所以这里提示编译器,只有很小的几率会调用到 foo()
再比如判断指针是否为空:
从汇编角度来说,会将可能性更大的汇编紧跟着前面的汇编,防止无效指令的加载。可以参考:
conditional move,条件传送。类似于 MOV 指令,但是依赖于 RFLAGS 寄存器内的状态。如果条件没有满足,该指令不会有任何效果。
CMOV 的优点是可以避免分支预测,避免分支预测错误对 CPU 流水线的影响。详细可以看这篇文档:
fbstring 在一些场景会提示编译器生成 CMOV 指令,例如:
我自己测试的情况貌似是搜索长字符串的情况会更好些。
c++20 引入了,判断会更加方便。