C++ 11 之std::thread

std::thread 是 C++11 引入的标准库类,用于表示和管理操作系统级别的线程。它提供了一种简单而强大的方式来创建多线程应用程序,并且能够直接操作线程的生命周期。以下是关于 std::thread 的详细介绍:

要启动一个新的线程,你可以通过将一个可调用对象(如函数、lambda 表达式或成员函数)传递给 std::thread 的构造函数来完成。

你还可以在线程中传递参数给线程函数或 lambda 表达式:

如果你想要在一个对象的方法中创建线程,你需要提供对象实例(可以是指针或引用)以及方法名:

为了防止多个线程之间的竞争条件,你可以使用互斥锁 (std::mutex) 和其他同步原语(如条件变量)来确保线程安全。

  • join():等待线程完成。这会阻塞当前线程直到被调用的线程执行完毕。
  • detach():分离线程,使其独立运行。分离后,你无法再通过 std::thread 对象控制该线程。
  • swap():交换两个 std::thread 对象的状态。
  • joinable():检查线程是否有效且未连接或分离。如果线程已经 join 或 detach,则返回 false。

C++11 还引入了线程局部存储的概念,允许每个线程拥有自己独立的变量副本。这可以通过 thread_local 关键字实现。

如果 std::thread 构造失败(例如由于系统资源不足),它会抛出 std::system_error 异常。

  • 创建和销毁线程是有成本的,因此对于频繁的任务,考虑使用线程池。
  • 尽量减少线程间的共享数据以降低同步开销。
  • 使用高效的同步机制,比如无锁编程或者原子操作,来提高性能。

总之,std::thread 提供了一个轻量级的接口来管理并发任务,但是开发者需要注意线程安全性和资源管理问题,以避免潜在的错误和性能瓶颈。

LinuxC++并发编程丨什么是无锁编程?以及如何实现?

锁定被迫交出时间片。

锁定意味着阻塞,多个线程(进程)排队获取资源,无法充分发挥系统性能。

锁定的阻塞无法通过fd进行通知,对性能有进一步的影响(理想的服务器模型是全局一处阻塞统一等待消息)。

一些锁限制了必须使用线程的方式进行开发,而线程无法充分利用系统的内存。

pthread库在特殊情况下可能产生饥饿的情况。

加锁的根本起因是什么?

资源竞争。

解决资源竞争的思路有哪些?

分资源:资源进一步分配,各个资源获得方不相往来。

分功能:对资源进行规划,各自处理不同功能。

做冗余:对资源进行冗余,对当前资源进行切换。

二次检查:不加锁执行后,检查是否被修改(CAS)。

少锁

原子操作与忙等待

CAS解法与ABA问题

seqlock

免锁

数据与进程对等的处理

单一生产者与单一消费者进程

下面让我们一个一个的来梳理无锁编程的内容吧。

ouble-checked Locking,严格意义上来讲不属于无锁范畴,无论什么时候当临界区中的代码仅仅需要加锁一次,同时当其获取锁的时候必须是线程安全的,此时就可以利用 Double-checked Locking 模式来减少锁竞争和加锁载荷。目前Double-checkedLocking已经广泛应用于单例 (Singleton)模式中。

Double-checked Locking有以下特点:

Double-checked Locking模式是Singleton的多线程版本。 Double-checked Locking模式依旧会使用锁——临界区锁定,不要以为可以避免使用锁。 Double-checked Locking解决的问题是:当多个线程存在访问临界区企图时,保证了临界区只需要访问一次。

以Singleton为例,为了防止多次分配,通常Singleton的实现方式是:

// 实现1

这里存在的问题是:无论是否已经初始化都要加锁,增加了负荷,已经没有所谓的并发性能了。

要增加并发性能,可以先判断是否已经分配,在没分配的情况下才加锁,也许你想要改成下面这个样子:

// 实现2

这里存在的问题是:不能保证临界区只初始化一次,没能实现singleton的基本功能。

// 实现3 – Double-checkedLocking

严格的说,Double-checked locking不属于无锁编程的范畴,但由原来的每次加锁访问到大多数情况下无须加锁,就是一个巨大的进步。

【文章福利】:小编整理了一些个人觉得比较好的学习书籍、视频资料共享在群文件里面,有需要的可以自行添加哦!~点击加入君羊(832218493需要自取)

原子操作可以保证指令以原子的方式执行——执行过程不被打断,原子操作是多数无锁编程的基本前提。

对1字节的读写

对2字节数(对齐到16位边界)读写

对4字节数(对齐到32位边界)读写

对8字节数(对齐到64位边界)读写

xchg

在x86平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀\”LOCK\”,经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。

LOCK是一个指令的描述符,表示后续的指令在执行的时候,在内存总线上加锁。总线锁会导致其他几个核在一定时钟周期内无法访问内存。虽然总线锁会影响其他核的性能,但比起操作系统级别的锁,已经轻量太多了。

#lock是锁FSB(前端串行总线,front serial bus),FSB是处理器和RAM之间的总线,锁住了它,就能阻止其他处理器或core从RAM获取数据。

声明和定义:

读写操作:

加一减一:

执行操作并且测试结果:执行操作之后,如果v是0,那么返回1,否则返回0

gcc内置的__sync_*函数提供了加减和逻辑运算的原子操作,__sync_fetch_and_add系列一共有十二个函数,有加/减/与/或/异或/等函数的原子性操作函数,__sync_fetch_and_add,顾名思义,先fetch,然后自加,返回的是自加以前的值。以count = 4为例,调用__sync_fetch_and_add(&count,1),之后,返回值是4,然后,count变成了5. 有__sync_fetch_and_add,自然也就有__sync_add_and_fetch,先自加,再返回。这两个的关系与i++和++i的关系是一样的。

type可以是1,2,4或8字节长度的int类型,即:

代码讲解1:使用__sync_fetch_and_add操作全局变量

代码讲解2:使用互斥锁mutex操作全局变量

结果说明:

[root@rocket lock-free]#./atom_add_gcc_buildin

count = 40000000, usetime = 756694 usecs

[root@rocket lock-free]# ./atom_add_mutex

count = 40000000, usetime = 3247131 usecs

可以看到,使用原子操作是使用互斥锁性能的5倍左右,随着冲突数量的增加,性能差距会进一步拉开。Alexander Sandler实测,原子操作性能大概是互斥锁的6-7倍左右。

有兴趣的同学请参考:

http://www.alexonlinux.com/multithreaded-simple-data-type-access-and-atomic-variables

xchg(ptr, new) 将ptr指向的值置为new,返回交换前的值。

cmpxchg(ptr, old, new) 比较当前值如果跟old相同,则将ptr指向的值置为new,否则不变,返回交换前的值。根据比较返回值是否和old一样来判断是否成功。

忙等待可以认为是一种特殊的忙等待

Peterson算法

xchg解法

TSL解法

自旋锁

Peterson算法是一个实现互斥锁的并发程序设计算法,可以控制两个线程访问一个共享的单用户资源而不发生访问冲突。GaryL. Peterson于1981年提出此算法。

结果说明:

[root@rocket lock-free]#./busywait_peterson

thread 0 run

thread 1 run

count = 3999851, usetime = 263132 usecs

可以看出,虽然是互斥算法,但是实测的结果缺不是十分精确,有少量的count丢失,这点让人感到很差异,这里先不去深究,有经验的同学可以帮忙分析一下原因。

结果说明:这个结果自然是非常精确,感觉比peterson算法靠谱多了,性能倒是差别不大。

[root@rocket lock-free]# ./busywait_xchg

count = 4000000, usetime = 166548 usecs

enter_region:

tsl register, lock |复制lock到寄存器,并将lock置为1

cmp register, #0 | lock等于0吗?

jne enter_region |如果不等于0,已上锁,再次循环

ret |返回调用程序,进入临界区

leave_region:

move lock, #0 |置lock为0

ret |返回调用程序

自旋锁请参考我的另一篇文章,这里不再赘述。

一般采用原子级的read-modify-write原语来实现Lock-Free算法,其中LL和SC是Lock-Free理论研究领域的理想原语,但实现这些原语需要CPU指令的支持,非常遗憾的是目前没有任何CPU直接实现了SC原语。根据此理论,业界在原子操作的基础上提出了著名的CAS(Compare-And-Swap)操作来实现Lock-Free算法,Intel实现了一条类似该操作的指令:cmpxchg8。

CAS原语负责将某处内存地址的值(1个字节)与一个期望值进行比较,如果相等,则将该内存地址处的值替换为新值,CAS 操作伪码描述如下:

CAS实际操作

do

{

备份旧数据;

基于旧数据构造新数据;

}while(!CAS(内存地址,备份的旧数据,新数据))

就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出CAS操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。

cmpxchg先比较内存地址的值是否与传入的值相等,如果相等则执行xchg逻辑。

CAS举例(简单应用AtomicInc)

一般的CAS在决定是否要修改某个变量时,会判断一下当前值跟旧值是否相等。如果相等,则认为变量未被其他线程修改,可以改。 但是,“相等”并不真的意味着“未被修改”。另一个线程可能会把变量的值从A改成B,又从B改回成A。这就是ABA问题。 很多情况下,ABA问题不会影响你的业务逻辑因此可以忽略。但有时不能忽略,这时要解决这个问题,一般的做法是给变量关联一个只能递增、不能递减的版本号。在compare时不但compare变量值,还要再compare一下版本号。 Java里的AtomicStampedReference类就是干这个的。

RCU就是指读-拷贝修改,它是基于其原理命名的。对于被RCU保护的共享数据结构,读操作不需要获得任何锁就可以访问,但写操作在访问它时首先拷贝一个副本,然后对副本进行修改,最后在适当的时机把指向原来数据的指针重新指向新的被修改的数据。这个时机就是所有引用该数据的CPU都退出对共享数据的操作。

Linux内核中内存管理大量的运用到了RCU机制。为每个内存对象增加了一个原子计数器用来继续该对象当前访问数。当没有其他进程在访问该对象时(计数器为0),才允许回收该内存。

从这个流程可以看出,RCU类似于一种读写锁的优化,用于解决读和写之间的同步问题。比较适合读多,写少的情况,当写操作过多的时候,这里的拷贝和修改的成本同样也很大。(写操作和写操作之间的同步还需要其它机制来保证)。

代码讲解:

结果说明:

[root@rocket lock-free]# ./lockfree_rcu

product begin

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

product begin

************************consumed hello, index0, self 1395513088

************************consumed hello, index0, self 1395513088

************************consumed hello, index0, self 1395513088

************************consumed hello, index0, self 1395513088

************************consumed hello, index0, self 1395513088

product begin

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

************************consumed world, index1, self 1395513088

用于能够区分读与写的场合,并且是读操作很多、写操作很少,写操作的优先权大于读操作。 seqlock的实现思路是,用一个递增的整型数表示sequence。写操作进入临界区时,sequence++;退出临界区时,sequence再++。写操作还需要获得一个锁(比如mutex),这个锁仅用于写写互斥,以保证同一时间最多只有一个正在进行的写操作。 当sequence为奇数时,表示有写操作正在进行,这时读操作要进入临界区需要等待,直到sequence变为偶数。读操作进入临界区时,需要记录下当前sequence的值,等它退出临界区的时候用记录的sequence与当前sequence做比较,不相等则表示在读操作进入临界区期间发生了写操作,这时候读操作读到的东西是无效的,需要返回重试。 seqlock写写是必须要互斥的。但是seqlock的应用场景本身就是读多写少的情况,写冲突的概率是很低的。所以这里的写写互斥基本上不会有什么性能损失。 而读写操作是不需要互斥的。seqlock的应用场景是写操作优先于读操作,对于写操作来说,几乎是没有阻塞的(除非发生写写冲突这一小概率事件),只需要做sequence++这一附加动作。而读操作也不需要阻塞,只是当发现读写冲突时需要retry。 seqlock的一个典型应用是时钟的更新,系统中每1毫秒会有一个时钟中断,相应的中断处理程序会更新时钟(写操作)。而用户程序可以调用gettimeofday之类的系统调用来获取当前时间(读操作)。在这种情况下,使用seqlock可以避免过多的gettimeofday系统调用把中断处理程序给阻塞了(如果使用读写锁,而不用seqlock的话就会这样)。中断处理程序总是优先的,而如果gettimeofday系统调用与之冲突了,那用户程序多等等也无妨。 seqlock的实现非常简单: 写操作进入临界区时:

写操作退出临界区时:

读操作尝试退出临界区时:

而读操作一般会这样进行:

场景:某服务需要支持海量用户,在一台物理机器上运行了多个进程/线程。对于数据应该如何处理以保证安全快速的访问数据呢?

解决方案:“分”

分号段

分进程

分端口

分库分表

场景:

网络接入进程与逻辑处理进程通过共享内存通讯。我们要如何进行设计?

通常的实现:读写加锁

示例:无锁内存队列的实现

场景:

1、 进程需要可以动态加载配置,我们需要怎么做?

2、 进一步,如果配置非常复杂,各个配置具有一定的依赖性,配置检查错误的话加载配置将会失败。我们如何设计才能安全、动态、无锁的加载配置?

通常的实现:

重启进程

发送信号

创建管理端口

示例1:直接访问共享内存

示例2:双配置缓冲区的实现

代码:

无锁CAS(附无锁队列的实现)

本文所有代码对应的Github链接为:https://github.com/dongyusheng/csdn-code/tree/master/cas_queue

锁是解决并发问题的,可是并发问题只有锁能解决吗?当然不是,CAS也可以解决并发问题

比较并交换(compare and swap,CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某⼀数据时由于执行顺序不确定性以及中断的不可预知性产⽣的数据不一致问题

有了CAS,我们就可以用它来实现各种无锁(lock free)的数据结构

该操作通过将内存中的值与指定数据进行比较,当数值⼀样时将内存中的数据替换为新的值

下面是两种int类型操作的CAS伪代码形式:

在上面的两种实现中第二种形式更好,因为它返回bool值让调用者知道是否更新成功

因为CAS是原子操作,所以在各种库的原子库中都有对应的CAS实现方式

对于gcc、g++编译器来讲,其原子操作中包含下面两个函数,是专门用来做CAS的

在Windows下,你可以使用下面的Windows API来完成CAS:

C++11标准库引入了原子操作,包含在<atomic>头文件中,下面是专门用于CAS操作的接口

相关视频推荐

学习地址:

需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享

此处我们只考虑队列出队列和进队列的并发问题:

出队列:出队列时,要保证只有一个线程在对头结点进行出队列的操作,否则就会发生错乱

入队列:入队列时,也一样,保证只有一个线程在对尾节点进行入队列的操作,否则就会发生错乱

无锁队列代码实现

上面为无锁队列的实现代码,我们假定此队列中头结点不存储数据(当做哨兵),尾节点存储数据

其使用到CAS的核心函数就是push()和pop()函数,在下面我们将_sync_bool_compare_and_swap()函数调用称之为CAS操作

push()如下:

假设线程T1和T2都执行push()函数,当线程T1先执行do-while中的CAS操作然后发现其尾节点后为空,于是就执行do-while中的CAS操作将尾节点_tail的_next指针赋值为newNode,然后退出do-while循环,调用第二个CAS操作将尾节点指针向后移动一位

由于CAS是一个原子操作,所以即使同时T2线程了也调用了do-while中的CAS操作,但是其判断p->_next不为空,因为T1线程已经将尾节点向后移动了,所以其只能继续执行do,将p向后移动,重新移动到尾节点继续重新判断,直到成功为止….

为什么push()函数的最后一个CAS操作不需要判断是否执行成功,因为:

1.如果有一个线程T1,它的while中的CAS如果成功的话,那么其它所有的随后线程的CAS都会失败,然后就会再循环

2.此时,如果T1线程还没有更新tail指针,其它的线程继续失败,因为tail->next不是NULL了

3.直到T1线程更新完tail指针,于是其它的线程中的某个线程就可以得到新的tail指针,继续往下走了

do作用域中为什么要使用while将p指针向后移动:

  • 假设T1线程在调用第二个CAS操作更新_tail指针之前,T1线程停掉或者挂掉了,那么其它线程就会进入死循环

pop()如下:

  • 原理与push()同理,假设线程T1和线程T2都执行pop()操作,假设T1先执行CAS操作将_head向后移动了一位,并且删除了原先的头指针
  • 那么当T2再执行时发现T1更新过后的_head指针(移动了)与一开始获取的头指针p不相等了,那么就继续执行do作用域重新获取头指针,然后重新进行CAS操作

测试代码

我们编写下面的程序测试一下无锁队列的各种操作是否有误,结果显示无误

下面我们将上面的无锁队列与C++ STL库中的queue进行对比,查看一下性能

queue_stl.cpp

其结果显示,执行10000000万次push和 10000000万次pop操作大概要1秒多的时间

queue_cas.cpp

其结果显示,执行10000000万次push和 10000000万次pop操作大概在1秒之内,没有超过1秒中

因此,无锁队列比使用mutex的效率要高一些

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。