【并发问题的互斥和同步】

本文探讨了并发编程中的关键概念,如线程的栈与堆特性、原子性、有序性、可见性、自旋锁、互斥锁、条件变量、信号量以及读写锁的使用。作者还强调了良好的编程习惯和内存安全工具的重要性。
摘要由CSDN通过智能技术生成

在多处理器系统中,并发无时无刻不在。即使是单处理器系统,由于中断的引入,以及时间片的切换,也涉及到并发情形。
在并发的情况下,如何保证资源的互斥和同步,保证指令执行的原子性和顺序性。基于一段时间对互斥和同步的学习,本文将结合自己的理解记录如下,如有偏差欢迎指正。

1. 线程独立栈共享堆

谈到并发,就不得不提到线程。线程是执行调度的最小单位,其拥有独立栈共享堆。

  1. 如何证明线程Ta和Tb有共享堆?
    使用全局变量A,在线程Ta和Tb都对其读写,并打印出A的值。

  2. 如何证明线程Ta和Tb有独立栈?
    使用局部变量a,修改Ta和Tb中的a值并打印,发现各自独立性

  3. 如何确定栈的大小?
    由于函数调用call会push CS和IP,考虑使用无穷递归,打印出起始到栈溢出时的SP区间;或者通过局部变量的地址来估算分配给线程的栈大小。

  4. 如何修改线程栈大小?
    pthread_attr_setstacksize函数可以设置线程栈大小,单位为字节B。
    linux命令中ulimit -s命令可以查看和临时修改栈大小,单位为KB。

2. 并发编程从入门到放弃

2.1 原子性

全局变量a++,看似一条指令,实际翻译成汇编指令有三条:

mov eax, [a]         //step1:将a的值从内存读到寄存器中
add eax, 1           //step2
mov [a], eax         //step3: 将寄存器的值写回内存

在三条指令执行过程中,如果有任意中断或者并发切换发生,并且都有a++,则step3可能互相覆盖,最终printf出的a值错误。

2.2 有序性

假设sum.c中有多个线程都会执行下述代码段:

for(i = 0; i < 10; i++)
{
	sum++;
}

此时:

gcc sum.c -O1 && ./a.out    (结果错误)
gcc sum.c -O2 && ./a.out   (结果正确)

why?可以通过objdump -d将上述代码反汇编得到汇编代码:

gcc sum.c -O1 -c
objdump -d sum.o > O1.txt
gcc sum.c -O2 -c
objdump -d sum.o > O2.txt

对比O1.txt和O2.txt,可以发现,不同的优化等级下,上述代码段会优化为:

-O1: R[eax] = sum; R[eax] += 10; sum = R[eax];   //三条指令
-O2: sum += N;                                   //一条指令

编译器只对顺序执行的指令负责,上述循环的指令,编译器会根据不同优化等级优化成不同的指令序列。在并发状态下,也就有了不确定性。
下面举例几个编译器优化的例子:

//循环判断done是否为0。当done等于0时,一直执行循环体;当done不为0时,跳出循环往下执行
while{!done}
{	
    ; //执行体
}

优化为:

//直接判断done是否为0。
//当done等于0时,一直死循环执行循环体,将无法再跳出循环,一直死循环,是不是很恐怖??
if!done)
{
	while(1)
	{
		; //执行体
	}
}
int t = x;
asm volatile ("" : : : "memory"); 
//没有中间这句,编译器会直接将两个t = x;优化成一个t = x;
//此处的memory是compiler barrier(编译屏障),保证编译器不会对内存操作进行优化
t = x;

2.3 可见性

有一个错误的假设:CPU执行指令时是按顺序执行的,只有执行完上一条指令,才会继续执行下一条指令。
实际上,在多处理器系统中,当两个独立变量x≠y。对x和y的内存读写是可以交换顺序的。现代处理器其实也是动态编译器,一切都以优化提高性能为目的。
另外在多核系统中,每个CPU都有自己的缓存(L1&&L2),通过硬件的协议一致性(MESI),实现最终的内存一致性。
比如x86多核系统中,允许写时暂时写入处理器本地的写队列,从而延迟对其他处理器的可见性。

题外:
还有一个错误假设:处理器一次执行一条指令。应该是处理器一次执行一条原子指令。
单处理器:线程会在执行过程中被打断,切换到另一个线程;
多处理器:线程根本就是并行执行的。

3. Scalability:性能的新维度

多个线程都要使用同一临界区的情况下,线程数越多,性能maybe越差。

4. 互斥:保证指令执行的原子性

现代多处理器系统,我们面临编译器和处理器的双重复杂行为。
互斥要满足的前提条件是:能正确处理处理器乱序,宽松内存模型和编译优化。

互斥 mutual exclusion
原子atomic指令
指令的执行不会被打断
包含一个compiler barrier
任何编译优化都不会跳出此范围
包含一个memory fence
lock/unlock,unlock之前所有对内存的写都已完成,确保unlock之后读正常

4.1 自旋锁:通过xchg对变量原子操作,获取不到锁一直忙等待

xchg将locked和另一个值交换,并返回locked交换之前的旧值。读锁+判断+加锁,原子操作,一键完成。

int locked = 0;
void lock(){
    //如果locked是1(说明此时锁被别人占了),则循环一直判断,直到locked为0,获取到锁退出
	while(xchg(&locked, 1));
}
void unlock(){
	xchg(&locked, 0);
}
int xchg(int volatile *ptr, int newval){
	int result;
	//volatile:禁止编译器优化从内存中读写数据
	asm volatile (
		"lock xchgl %0, %1"   //lock锁地址总线(低电平),memory barrier
		: "+m"(*ptr), "=a"(result)  // output
		: "1"(newval)               // input
		: "memory"   //compiler barrier,确保编译器不对内存操作进行优化
	    );
    return result;
}

自旋锁适用于:

  • 临界区较短
  • 持有自旋锁时禁止中断,最好不要有执行流切换

4.2 互斥锁:通过系统调用访问locked,开销大

syscall (SYSCALL_lock, &lk);
系统调用尝试获取lk,失败则sleep,cpu切换
syscall (SYSCALL_unlock, &lk);
系统调用释放lk,同时唤醒其他等待的线程

5. 同步:保证指令执行的顺序性

线程同步由条件不成立等待同步条件达成继续构成

5.1 条件变量:持有互斥锁的前提下,检查 条件 是否满足

mutex_lock(&mutex);
while(!cond)
{
	wait(&cv, &mutex); //wait会释放锁再sleep。被唤醒后会重新获取锁
}
assert(cond);	
; //执行体	
broadcast(&cv); //广播,或者使用两个条件变量cv1和cv2,signalcast唤醒另一个线程
	
mutex_unlock(&mutex);

条件变量(生产者-消费者问题),万能并行计算框架:

wait_until(cond) with (mutex) {
// cond 在此时成立
work();
}

5.2 信号量:当资源数初始设置为1时,即为互斥锁

struct semaphore{
    raw_spinlock_t lock;  //保护资源数修改时的自旋锁.当资源为0时,会调用sleep睡眠
    unsigned int res_count; //资源数
    struct list_head wait_list;  //挂载睡眠等待线程的链表
};

条件变量需要考虑单播还是广播唤醒其他线程,而信号量是条件变量的特例:

  • P – prolaag(try + decrease/down/wait/acquire)
    试着从袋子里拿球,失败立即等待睡眠
void P(sem_t *sem) { // wait
  wait_until(sem->count > 0) {
    sem->count--;
  }
}
  • V – verhoog (increase/up/post/signal/release)
    往袋子里放球,唤醒任意等待的线程
void V(sem_t *sem) { // up
  atomic {
    sem->count++;
  }
}

信号量的两种典型应用

  • 实现一次临时的 happens-before
    初始:s = 0
    A; V(s)
    P(s); B
    假设 s 只被使用一次,保证 A happens-before B
  • 实现计数型的同步 – 类似pthread_join的实现(主线程等待所有线程结束才结束)
    初始:done = 0
    Tworker: V(done)
    Tmain: P(done) * T

5.3 读写锁:适用于对共享资源读多写少的情况

读写互斥,写写互斥,读读不互斥,写高优于读
实现原理:基于计数器的自旋锁+sleep

初始值为0x0100 0000
获取读锁时判断其值是否大于0,Yes则减一获取读锁成功,No则睡眠等待加入读队列;
写锁获取时会判断其值是否等于0x0100 0000,Yes则减去0x0100 0000获取写锁成功,No则睡眠等待加入写队列。

思考:一直在读的情况下,写饥饿如何处理?
增加优先级,写队列高优于读队列

并发控制基础
互斥:指令的原子性保证
同步:指令的顺序性保证
自旋锁
优点: 通过xchg对变量原子操作,无需syscall,获取到锁立即进入临界区,开销小
缺点:浪费cpu自旋忙等待
互斥锁
优点:上锁失败后不忙等,cpu利用率高
缺点: 即使上锁成功也需要syscall进出内核,开销大
条件变量
带着互斥锁判断条件是否成立,不成立则wait释放锁再sleep,唤醒时尝试重新获取锁
99% 的实际并发问题都可以用生产者-消费者解决
信号量
happens-before,实现 一件事等待另一件事完成才能开始 的效果
读写锁
读写互斥,写写互斥,读读不互斥,写高优于读

6. Good coding habits

  1. 把程序需要满足的条件,用断言assert表达出来。
assert(cond);
cond为true,则继续往下执行
cond为false,打印退出
  
  1. 善用宏#define,预定义一些变量的检查和printf。
#ifdef LOCAL_MACHINE
    #define debug(...) printf(__VA_ARGS__)
#else
    #define debug(...)
#endif
  1. lockdep检查,发现死锁问题
    存在锁嵌套时,添加加锁和解锁的行号打印,保证不存在循环等待的情况。
void lock(lock_t *lk) {
  printf("LOCK   %d\n", __LINE__);
}

void unlock(lock_t *lk) {
  printf("UNLOCK %d\n", __LINE__);
}
  1. Sanitizer编译器检查
  1. AddressSanitizer (ASan):
    • 编译器选项:使用 -fsanitize=address。
    • 功能:检测Buffer (heap/stack/global) overflow, use-after-free, use-after-return, double-free, …
  2. ThreadSanitizer (TSan):
    • 编译器选项:使用 -fsanitize=thread。
    • 功能:检测数据竞争和死锁。
  3. MemorySanitizer (MSan):
    • 编译器选项:使用 -fsanitize=memory。
    • 功能:检测使用未初始化内存的情况。
  4. UndefinedBehaviorSanitizer (UBSan):
    • 编译器选项:使用 -fsanitize=undefined。
    功能:检测和修复未定义行为,如整数溢出、空指针解引用等。
  1. 保护栈空间(stack guard)
    牺牲一些内存用来做检查,防止memory error的情况
#define MAGIC 0x55555555
#define BOTTOM (STK_SZ / sizeof(u32) - 1)
struct stack { char data[STK_SZ]; };

void canary_init(struct stack *s) {
  u32 *ptr = (u32 *)s;
  for (int i = 0; i < CANARY_SZ; i++)
    ptr[BOTTOM - i] = ptr[i] = MAGIC;
}

void canary_check(struct stack *s) {
  u32 *ptr = (u32 *)s;
  for (int i = 0; i < CANARY_SZ; i++) {
    panic_on(ptr[BOTTOM - i] != MAGIC, "underflow");
    panic_on(ptr[i] != MAGIC, "overflow");
  }
}

致谢:

蒋炎岩老师WiKi–绿导师原谅你了

  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值