c++并行编程中的“锁”难题

c++并行编程中的“锁”难题

 

linux服务器开发相关视频解析:

在并行程序中,锁的使用会主要会引发两类难题:一类是诸如死锁、活锁等引起的多线程Bug;另一类是由锁竞争引起的性能瓶颈。本文将介绍并行编程中因为锁引发的这两类难题及其解决方案。

1、用锁来防止数据竞跑

在进行并行编程时,我们常常需要使用锁来保护共享变量,以防止多个线程同时对该变量进行更新时产生数据竞跑(Data Race)。所谓数据竞跑,是指当两个(或多个)线程同时对某个共享变量进行操作,且这些操作中至少有一个是写操作时所造成的程序错误。例1中的两个线程可能同时执行“counter++”从而产生数据竞跑,造成counter最终值为1(而不是正确值2)。

例1:

#include <pthread.h>
int counter = 0;
void *func(void *params)
{counter++; //数据竞跑
}
void main()
{pthread_t thread1, thread2;pthread_create(&thread1, 0, func, 0);pthread_create(&thread2, 0, func, 0);pthread_join(thread1, 0 );pthread_join(thread2, 0 );
}

这是因为counter++本身是由三条汇编指令构成的(从主存中将counter的值读到寄存器中;对寄存器进行加1操作;将寄存器中的新值写回主存),所以例1中的两个线程可能按如下交错顺序执行,导致counter的最终值为1:

例2:

load [%counter], rax; // 线程1从counter读取0到寄存器rax
add rax, 1; // 线程1对寄存器rax进行加1
load [%counter], rbx; // 线程2从counter读取0到寄存器rbx
store rax [%counter]; // 线程1把1写入counter的主存地址
add rbx, 1; // 线程2对寄存器rbx进行加1
store rbx, [%counter]; // 线程2把1写入counter的主存地址

为了防止例1中的数据竞跑现象,我们可以使用锁来保证每个线程对counter++操作的独占访问(即保证该操作是原子的)。在例3的程序中,我们使用mutex锁将counter++操作放入临界区中,这样同一时刻只有获取锁的线程能访问该临界区,保证了counter++的原子性:即只有在线程1执行完counter++的三条指令之后线程2才能执行counter++操作,保证了counter的最终值必定为2。

例3:

#include <pthread.h>
int counter = 0;
pthread_mutex_t mutex;
void *func(void *params)
{pthread_mutex_lock(&mutex);counter++; //处于临界区,不会产生数据竞跑pthread_mutex_unlock(&mutex);
}
void main()
{pthread_t thread1, thread2;pthread_mutex_init(&mutex);pthread_create(&thread1, 0, func, 0);pthread_create(&thread2, 0, func, 0);pthread_join(thread1, 0 );pthread_join(thread2, 0 );pthread_mutex_destroy(&mutex);
}

2. 死锁和活锁

然而,锁的使用非常容易导致多线程Bug,最常见的莫过于死锁和活锁。从原理上讲,死锁的产生是由于两个(或多个)线程在试图获取正被其他线程占有的资源时造成的线程停滞。在下例中,假设线程1在获取mutex_a锁之后正在尝试获取mutex_b锁,而线程2此时已经获取了mutex_b锁并正在尝试获取mutex_a锁,两个线程就会因为获取不到自己想要的资源、且自己正占有着对方想要的资源而停滞,从而产生死锁。

例4:

// 线程 1                     
void func1()                    
{                       LOCK(&mutex_a);                      LOCK(&mutex_b);//线程1停滞在此         counter++;                       UNLOCK(&mutex_b);                     UNLOCK(&mutex_a);                    
}                       // 线程 2
void func2()
{LOCK(&mutex_b);LOCK(&mutex_a);//线程2停滞在此counter++;UNLOCK(&mutex_a);UNLOCK(&mutex_b);
}

例4中的死锁其实是最简单的情形,在实际的程序中,死锁往往发生在复杂的函数调用过程中。在下面这个例子中,线程1在func1()中获取了mutex_a锁,之后调用func_call1()并在其函数体中尝试获取mutex_b锁;与此同时线程2在func2()中获取了mutex_b锁之后再在func_call2()中尝试获取mutex_a锁从而造成死锁。可以想象,随着程序复杂度的增加,想要正确的检测出死锁会变得越来越困难。

例5:

// 线程 1                     
void func1()                    
{                       
LOCK(&mutex_a);                      
...                     
func_call1();                
UNLOCK(&mutex_a);                
}                       func_call1()                    
{                       LOCK(&mutex_b);               ...                       UNLOCK(&mutex_b);                 ...                       
}                       // 线程 2
void func2()
{LOCK(&mutex_b);...func_call2()UNLOCK(&mutex_b);
}func_call2()
{LOCK(&mutex_a);...UNLOCK(&mutex_b);...
}

其实避免死锁的方法非常简单,其基本原则就是保证各个线程加锁操作的执行顺序是全局一致的。例如,如果上例中的线程1和线程2都是先对mutex_a加锁再对mutex_b进行加锁就不会产生死锁了。在实际的软件开发中,除了严格遵守相同加锁顺序的原则防止死锁之外,我们还可以使用RAII(Resource Acquisition Is Initialization,即“资源获取即初始化”)的手段来封装加锁解锁操作,从而帮助减少死锁的发生[1]。

除死锁外,多个线程的加锁、解锁操作还可能造成活锁。在下例中,程序员为了防止死锁的产生而做了如下处理:当线程1在获取了mutex_a锁之后再尝试获取mutex_b时,线程1通过调用一个非阻塞的加锁操作(类似pthread_mutex_trylock)来尝试进行获得mutex_b:如果线程1成功获得mutex_b,则trylock()加锁成功并返回true,如果失败则返回false。线程2也使用了类似的方法来保证不会出现死锁。不幸的是,这种方法虽然防止了死锁的产生,却可能造成活锁。

例如,在线程1获得mutex_a锁之后尝试获取mutex_b失败,则线程1会释放mutex_a并进入下一次while循环;如果此时线程2在线程1进行TRYLOCK(&mutex_b)的同时执行TRYLOCK(&mutex_a),那么线程2也会获取mutex_a失败,并接着释放mutex_b及进入下一次while循环;如此反复,两个线程都可能在较长时间内不停的进行“获得一把锁、尝试获取另一把锁失败、再解锁之前已获得的锁“的循环,从而产生活锁现象。当然,在实际情况中,因为多个线程之间调度的不确定性,最终必定会有一个线程能同时获得两个锁,从而结束活锁。尽管如此,活锁现象确实会产生不必要的性能延迟,所以需要大家格外注意。

例6:

// 线程 1                     
void func1()                    
{                       int done = 0;                   while(!done) {               LOCK(&mutex_a);                      if (TRYLOCK(&mutex_b)) {               counter++;                   UNLOCK(&mutex_b);                        UNLOCK(&mutex_a);                        done = 1;                        }                          else {                     UNLOCK(&mutex_a);                   }                          }                        
}                       // 线程 2
void func2()
{int done = 0;while(!done) {LOCK(&mutex_b);if (TRYLOCK(&mutex_a)) {counter++;UNLOCK(&mutex_a);UNLOCK(&mutex_b);done = 1; }else {UNLOCK(&mutex_b);}}
}

 

 

3. 锁竞争性能瓶颈

在多线程程序中锁竞争是最主要的性能瓶颈之一。在前面我们也提到过,通过使用锁来保护共享变量能防止数据竞跑,保证同一时刻只能有一个线程访问该临界区。但是我们也注意到,正是因为锁造成的对临界区的串行执行导致了并行程序的性能瓶颈。

3.1阿姆达尔法则(Amdahl’s Law)

在介绍锁竞争引起的性能瓶颈之前,让我们先来了解一下阿姆达尔法则。我们知道,一个并行程序是由两部分组成的:串行执行的部分和可以并行执行的部分。假设串行部分的执行时间为S,可并行执行部分的执行时间为P,则整个并行程序使用单线程(单核)串行执行的时间为S+P。阿姆达尔法则规定,可并行执行部分的执行时间与线程数目成反比:即如果有N个线程(N核CPU)并行执行这个可并行的部分,则该部分的执行时间为P/N。由此我们可以得到并行程序总体执行时间的公式:

总体执行时间 T = S + P/N

根据这个公式,我们可以得到一些非常有意思的结论。例如,如果一个程序全部代码都可以被并行执行,那么它的加速比会非常好,即随着线程数(CPU核数)的增多该程序的加速比会线性递增。换句话说,如果单线程执行该程序需要16秒钟,用16个线程执行该程序就只需要1秒钟。

然而,如果这个程序只有80%的代码可以被并行执行,它的加速比却会急剧下降。根据阿姆达尔法则,如果用16个线程并行执行此程序可并行的部分,该程序的总体执行时间T = S + P/N = (160.2) + (160.8)/16 = 4秒,这比完全并行化的情况(只需1秒)足足慢了4倍!实际上,如果该程序只有50%的代码可以被并行执行,在使用16个线程时该程序的执行时间仍然需要8.5秒!

从阿姆达尔法则我们可以看到,并行程序的性能很大程度上被只能串行执行的部分给限制住了,而由锁竞争引起的串行执行正是造成串行性能瓶颈的主要原因之一。

3.2锁竞争的常用解决办法

3.2.1 避免使用锁

为了提高程序的并行性,最好的办法自然是不使用锁。从设计角度上来讲,锁的使用无非是为了保护共享资源。如果我们可以避免使用共享资源的话那自然就避免了锁竞争造成的性能损失。幸运的是,在很多情况下我们都可以通过资源复制的方法让每个线程都拥有一份该资源的副本,从而避免资源的共享。如果有需要的话,我们也可以让每个线程先访问自己的资源副本,只在程序的后将各个线程的资源副本合并成一个共享资源。例如,如果我们需要在多线程程序中使用计数器,那么我们可以让每个线程先维护一个自己的计数器,只在程序的最后将各个计数器两两归并(类比二叉树),从而最大程度提高并行度,减少锁竞争。

3.2.2 使用读写锁

如果对共享资源的访问多数为读操作,少数为写操作,而且写操作的时间非常短,我们就可以考虑使用读写锁来减少锁竞争。读写锁的基本原则是同一时刻多个读线程可以同时拥有读者锁并进行读操作;另一方面,同一时刻只有一个写进程可以拥有写者锁并进行写操作。读者锁和写者锁各自维护一份等待队列。当拥有写者锁的写进程释放写者锁时,所有正处于读者锁等待队列里的读线程全部被唤醒并被授予读者锁以进行读操作;当这些读线程完成读操作并释放读者锁时,写者锁中的第一个写进程被唤醒并被授予写者锁以进行写操作,如此反复。

换句话说,多个读线程和一个写线程将交替拥有读写锁以完成相应操作。这里需要额外补充的一点是锁的公平调度问题。例如,如果在写者锁等待队列中有一个或多个写线程正在等待获得写者锁时,新加入的读线程会被放入读者锁的等待队列。这是因为,尽管这个新加入的读线程能与正在进行读操作的那些读线程并发读取共享资源,但是也不能赋予他们读权限,这样就防止了写线程被新到来的读线程无休止的阻塞。

需要注意的是,并不是所有的场合读写锁都具备更好的性能,大家应该根据Profling的测试结果来判断使用读写锁是否能真的提高性能,特别是要注意写操作虽然很少但很耗时的情况。

3.2.3 保护数据而不是操作

在实际程序中,有不少程序员在使用锁时图方便而把一些不必要的操作放在临界区中。例如,如果需要对一个共享数据结构进行删除和销毁操作,我们只需要把删除操作放在临界区中即可,资源销毁操作完全可以在临界区之外单独进行,以此增加并行度。正是因为临界区的执行时间大大影响了并行程序的整体性能,我们必须尽量少在临界区中做耗时的操作,例如函数调用,数据查询,I/O操作等。简而言之,我们需要保护的只是那些共享资源,而不是对这些共享资源的操作,尽可能的把对共享资源的操作放到临界区之外执行有助于减少锁竞争带来的性能损失。

3.2.4 尽量使用轻量级的原子操作

在例3中,我们使用了mutex锁来保护counter++操作。实际上,counter++操作完全可以使用更轻量级的原子操作来实现,根本不需要使用mutex锁这样相对较昂贵的机制来实现。

3.2.5 粗粒度锁与细粒度锁

为了减少串行部分的执行时间,我们可以通过把单个锁拆成多个锁的办法来较小临界区的执行时间,从而降低锁竞争的性能损耗,即把“粗粒度锁”转换成“细粒度锁”。但是,细粒度锁并不一定更好。这是因为粗粒度锁编程简单,不易出现死锁等Bug,而细粒度锁编程复杂,容易出错;而且锁的使用是有开销的(例如一个加锁操作一般需要100个CPU时钟周期),使用多个细粒度的锁无疑会增加加锁解锁操作的开销。在实际编程中,我们往往需要从编程复杂度、性能等多个方面来权衡自己的设计方案。

事实上,在计算机系统设计领域,没有哪种设计是没有缺点的,只有仔细权衡不同方案的利弊才能得到最适合自己当前需求的解决办法。例如,Linux内核在初期使用了Big Kernel Lock(粗粒度锁)来实现并行化。从性能上来讲,使用一个大锁把所有操作都保护起来无疑带来了很大的性能损失,但是它却极大地简化了并行整个内核的难度。当然,随着Linux内核的发展,Big Kernel Lock已经逐渐消失并被细粒度锁而取代,以取得更好的性能。

3.2.6 使用无锁算法、数据结构

首先要强调的是,笔者并不推荐大家自己去实现无锁算法。为什么别去造无锁算法的轮子呢?因为高性能无锁算法的正确实现实在是太难了。事实上,我推荐大家直接去使用一些并行库中已经实现好了的无锁算法、无锁数据结构,以提高并行程序的性能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/56615.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

恒力石化、茂名天源石化等项目开工 2025年广东石化产业营收力争超2万亿

目前来看&#xff0c;广东省已经拥有诸多国外化工巨头、大型民营炼化企业和不少国企的炼化项目&#xff0c;成为很多石化企业首选的项目落地基地。“石化业高质量发展看广东”&#xff0c;已经逐渐明朗。 今年3月31日&#xff0c;广东省发展改革委官网公布《广东省2021年重点建…

Linux中的线程

1.线程的基本概念 2.线程和进程的区别 线程安全 *线程的同步 线程的调度 线程的通信编程思想之多线程与多进程(1)——以操作系统的角度述说线程与进程_阳光日志-CSDN博客_多线程和多进程编程线程是什么&#xff1f;要理解这个概念&#xff0c;须要先了解一下操作系统的一些…

武汉超级计算机中心,加快打造“五个中心” 武汉率先开建人工智能计算中心...

(来源&#xff1a;武汉市发改委) 原标题&#xff1a;加快打造“五个中心” 武汉率先开建人工智能计算中心 从华为东莞松山湖基地运来的预制化模块箱体&#xff0c;正在光谷科学岛起步区被吊装&#xff0c;未来将被“拼装”成武汉重要的人工智能算力基础设施。3月1日&#xff0c…

产业丨国产数据库行业火热,厂商各有各的算盘

前言&#xff1a; 随着数字经济的不断发展&#xff0c;未来数据库发展有着四个趋势&#xff1a;开源、HTAP、云原生以及和大数据技术融合。 如今&#xff0c;随着众多企业入局&#xff0c;国产数据库正在打着一场激烈的翻身仗。 前言&#xff1a; 随着数字经济的不断发展&a…

不到一年涨粉849万,“神级账号”小鱼海棠背后成功的秘诀是什么?

如何能在短时间内精确吸粉百万&#xff0c;快速度过账号冷启动&#xff0c;跻身头部网红的行列&#xff1f;快手视频博主“小鱼海棠”就交出了一份完美的答卷。 凭借牢牢抓住与999个帅哥拍照这一创意点&#xff0c;“小鱼海棠”一战封神&#xff0c;成为网红界一匹飞速狂奔的黑…

灿谷集团荣获“公益践行奖”

2022年1月13-14日&#xff0c;“第十一届公益节暨企业社会责任嘉年华”在上海举行。在公益节上&#xff0c;灿谷集团凭借在履行企业社会责任方面的杰出表现&#xff0c;获评“2021年度公益践行奖”。 公益节设立于2011年&#xff0c;是国内首个由大众媒体联袂发起的以“公益”…

阿里P7被裁员,找工作小半年了,流程走着走着就没了

上一篇&#xff1a;幸好没去虾皮&#xff01; 互联网大厂&#xff0c;35岁中年人的困境 在很多行业&#xff0c;35岁正是事业发展黄金期&#xff0c;但在大厂&#xff0c;35岁员工担心竞争力下滑、被年轻人替代、找不到下家...。 “员工35岁被劝退”“高龄员工不续签”&#xf…

独家!起底蚂蚁集团灵活用工服务 源自税筹圈

中国灵活用工市场发展20余年&#xff0c;进入了快速成长阶段。灵活用工成为了时下主流的用工方式&#xff0c;为了达到降本增效的目的&#xff0c;越来越多的企业选择与灵活用工平台合作。 灵活用工产业角色方包括用工单位、用人单位和外包员工&#xff0c;其中的主角&#xff…

P2E的元宇宙赛车PVE正式开启,Supercars的全新赋能征程

前言&#xff1a; Luna近期崩盘的热度火遍全球&#xff0c;公链的安全事故导致ETH链上大量的资金开始出逃。一鲸落&#xff0c;万物生&#xff0c;市场热度开始由DeFi赛道又开始回归到了链游方向。以StepN为主的P2E&#xff0c;M2E在数据层面又开始逐渐变好&#xff0c;市场最…

计算广告(一)

计算广告学是一个十分庞大的学科&#xff0c;里面涵盖了自然语言处理、机器学习、推荐系统等众多研究方向。而且广告作为互联网行业的三大盈利模式&#xff08;广告、电商、游戏&#xff09;之一&#xff0c; 也是这三大模式中最有技术含量的&#xff0c;计算广告学一直都吸引…

献给青春的歌 · 致「 腾讯QQ 18 岁」

博主说&#xff1a;在中国为期不长的互联网历史上&#xff0c;太多的 IT 产品如昙花般匆匆一现&#xff0c;腾讯能够走过 18 载春秋&#xff0c;出人意料又在情理之中。但对于我们来说&#xff0c;腾讯QQ 绝并不仅仅只是一个用于聊天的软件而已&#xff0c;因为它更承载着我们青…

触宝占据内容赛道有利位置:疯读高速发展,或将迎来价值修复时刻

撰稿 | 多客 来源 | 贝多财经 近年来&#xff0c;移动互联网的快速发展&#xff0c;使得战略转型成为一个持续性的话题。 如百度从搜索到AI的转变&#xff0c;如平安集团从金融到科技的调整&#xff0c;众多互联网大厂也纷纷瞄准内容赛道&#xff0c;譬如字节跳动、趣头条、阅…

OEM“竞跑”:智能电动+本地化

高市值&#xff0c;是支撑汽车制造商继续智能电动汽车赛道征战的门票。 近日&#xff0c;戴姆勒首席执行官Ola Kallenius正在为这家传统豪华汽车制造商寻求更高的估值。去年12月戴姆勒的卡车部门被分拆后&#xff0c;这家汽车制造商将在今年2月1日正式更名为梅赛德斯奔驰&…

竞跑加速! 数字人民币场景全覆盖

进入8月仅数日&#xff0c;数字人民币各地试点都传来新进展&#xff0c;继北京轨道交通实现数字人民币全覆盖后&#xff0c;8月8日&#xff0c;北京商报记者梳理发现&#xff0c;包括北京、上海、苏州、广东、大连、青岛、福州、西安等多地试点消息不断&#xff0c;从应用场景来…

讲解冲压模具设计“高凸成型”工艺

讲解冲压模具设计“高凸成型”工艺 模具设计结构需要进行规范化才能规避大部分理论问题&#xff0c;规范的凸包成型结构&#xff0c;能更好的打出合格产品&#xff0c;下面我们就一起来聊聊“高凸成型结构”。 高凸形状 对于这种凸包较高结构的产品一般属于大件&#xff0c;…

UG模具设计的八大分模方法,建议收藏

UG模具设计的八大分模方法&#xff0c;建议收藏 一、经典方法: 也就是最基本的方法COPY SURFACE,这是一位台湾教授教材上讲得最多的一种方法;也是最土的方法&#xff01;如果用此方法分一些复杂模具的话比较麻烦! 二、切割法: 许多的时候,当我们做好分型面后进行分模才发现…

分享UG塑胶模具设计的分模方法,一起学起来

分享UG塑胶模具设计的分模方法&#xff0c;一起学起来 一、经典方法: 也就是最基本的方法COPY SURFACE,这是一位台湾教授教材上讲得最多的一种方法;也是最土的方法&#xff01;如果用此方法分一些复杂模具的话比较麻烦! 二、切割法: 许多的时候,当我们做好分型面后进行分模才…

AI数字人直播新浪潮,灰豚AI数字人打造数字化直播新科技!

近年来&#xff0c;随着5G技术的不断发展&#xff0c;人工智能技术被广泛应用到各行各业中&#xff0c;数字化转型成为各大企业和商家转型趋势。其中&#xff0c;AI数字人直播是互联网中最受关注的新趋势之一。 那么&#xff0c;AI数字人是什么呢&#xff1f; AI数字人是通过人…

内测了下阿里的AI画图,带来了点大厂的震撼

阿里的 AI 绘画创作大模型「通义万相」成为今年 WAIC 世界人工智能大会的主角之一。这场大会持续三天&#xff0c;将有30多个大模型陆续亮相。 「通义万相」是基于阿里自研的组合式生成模型 Composer 开发的。它在 AI 画图领域展现出了强大的创作能力&#xff0c;给人们带来了视…

销售人员的月工资数量(月工资=基本工资+提成,提成=商品数*1.5)

#include <stdio.h> int main() { float data,sum; printf(“请输入销售额&#xff1a;”); scanf("%f",&data); sum3000data*1.5; printf(“工资&#xff1a;”); printf("%.1f\n",sum); //.1结果保留一位小数 return 0; }