typename std::iterator_traits<_Iterator1>::reference _M_ref 这串C++代码是什么意思?

如同大多数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() 才会阻塞当前线程,并且在收到其他线程的通知后只有当 predtrue 时才会被解除阻塞.

内容同步发表在公众号文章 : ,欢迎关注 : )
  • string 常见的三种实现方式
    • 特殊的构造函数 —— 不拷贝用户传入的字符串

在引入之前,我们首先再回顾一下 string

string 常见的三种实现方式

  • char *data. 指向存放字符串的首地址(在 SSO 的某些实现方案中可能没有此字段)。

  • 每个对象互相独立,不用考虑那么多乱七八糟的场景。
  • 字符串较大时,拷贝浪费空间。

这个也算是计算机里的基本思想了。不同于 eager copy 的每次拷贝都会复制,此种实现方式为写时复制,即 copy-on-write。只有在某个 string 要对共享对象进行修改时,才会真正执行拷贝。

由于存在共享机制,所以需要一个std::atomic<size_t>,代表被多少对象共享。

  • 字符串空间较大时,减少了分配、复制字符串的时间。
  • refcount 需要原子操作,性能有损耗。
  • 某些情况下会带来意外的开销。比如非 const 成员使用[],这会触发 COW,因为无法知晓应用程序是否会对返回的字符做修改。典型的如中举的例子:
  • 其他更细节的缺点可以参考:

Small String Optimization. 基于字符串大多数比较短的特点,利用 string 对象本身的栈空间来存储短字符串。而当字符串长度大于某个临界值时,则使用 eager copy 的方式。

SSO 下,string 的数据结构会稍微复杂点,使用 union 来区分短字符串和长字符串的场景:

这种数据结构的实现下,SSO 的阈值一般是 15 字节。folly 的 fbstring 在 SSO 场景下,数据结构做了一些优化,可以存储 23 个字节,后面会提到。

  • 短字符串时,无动态内存分配。

  • COW 存储时对于引用计数线程安全。
  • 对 Jemalloc 友好。如果检测到使用 jemalloc,那么将使用 jemalloc 的一些非标准扩展接口来提高性能。
  • 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 更加方便的取该结构最后一个字节。

配合上面三种类型字符串的存储,可以很容易理解这一行代码。

    • 当字符串引用大于 1 时,直接返回 size。因为此时的 capacity 是没有意义的,任何 append data 操作都会触发一次 cow

对着上面的每种情况下字符串的存储的图,应该很好理解,这里不细说了。

首先 fbstring_core 的构造函数中,根据字符串的长度,调用 3 种不同类型的初始化函数:

folly 会通过 canNallocx 函数检测是否使用 jemalloc,如果是,会使用 jemalloc 来提高内存分配的性能。关于 jemalloc 我也不是很熟悉,感兴趣的可以查查,有很多资料。

  • 再通过 checkedMalloc 真正申请内存,存放字符串。

特殊的构造函数 —— 不拷贝用户传入的字符串

fbstring 提供了一个特殊的构造函数,让 fbstring_core 接管应用程序自己分配的内存。

可以看出这里没有拷贝字符串的过程,而是直接接管了上游传递过来的指针指向的内存。但是,正如注释说的,这里直接使用了 medium strings 的存储方式。

同初始化,也是根据不同的字符串类型,调用不同的函数:

正如注释中所说,虽然 small strings 的情况下,字符串存储在 small中,但是我们只需要把 ml直接赋值即可,因为在一个 union 中。

  • 为字符串分配空间、拷贝

  • 直接赋值 ml,内含指向共享字符串的指针。
  • 共享字符串的引用计数加 1。
// 获取data_[1]在结构体的偏移量再相减,得到的就是所属RefCounted的地址

  • 如果是 small 类型,直接返回,因为利用的是栈空间。
  • medium : free 动态分配的字符串内存即可。

逻辑也很清晰:先对引用计数减 1,如果本身就只有 1 个引用,那么直接 free 掉整个 RefCounted。

最重要的一点,也是 large strings 独有的,就是 COW. 任何针对字符串写的操作,都会触发 COW,包括前面举过的[]操作,例如:

  • 对原有的共享字符串减少一个引用 decrementRefs,这个函数在上面的析构小节里分析过。
  • 注意此时还不会设置 size,因为还不知道应用程序对字符串进行什么修改。

所以,当字符串用到[]、at 且不需要写操作时,最好用 const-qualifer.

我们拿 folly 自带的测试一下:

从注释和代码看为什么函数起名叫smartRealloc :

给编译器提供分支预测信息。原型为:

表达式的返回值为 exp 的值,跟 c 无关。 我们预期 exp 的值是 c。例如下面的例子,我们预期 x 的值是 0,所以这里提示编译器,只有很小的几率会调用到 foo()

再比如判断指针是否为空:

从汇编角度来说,会将可能性更大的汇编紧跟着前面的汇编,防止无效指令的加载。可以参考:

conditional move,条件传送。类似于 MOV 指令,但是依赖于 RFLAGS 寄存器内的状态。如果条件没有满足,该指令不会有任何效果。

CMOV 的优点是可以避免分支预测,避免分支预测错误对 CPU 流水线的影响。详细可以看这篇文档:

fbstring 在一些场景会提示编译器生成 CMOV 指令,例如:

  • 大致的算法和原理可以参考 facebook 的这篇博客 :

我自己测试的情况貌似是搜索长字符串的情况会更好些。

c++20 引入了,判断会更加方便。


我要回帖

更多关于 list traits 的文章

 

随机推荐