在多处理器系统中,并发无时无刻不在。即使是单处理器系统,由于中断的引入,以及时间片的切换,也涉及到并发情形。
在并发的情况下,如何保证资源的互斥和同步,保证指令执行的原子性和顺序性。基于一段时间对互斥和同步的学习,本文将结合自己的理解记录如下,如有偏差欢迎指正。
1. 线程独立栈共享堆
谈到并发,就不得不提到线程。线程是执行调度的最小单位,其拥有独立栈共享堆。
-
如何证明线程Ta和Tb有共享堆?
使用全局变量A,在线程Ta和Tb都对其读写,并打印出A的值。 -
如何证明线程Ta和Tb有独立栈?
使用局部变量a,修改Ta和Tb中的a值并打印,发现各自独立性 -
如何确定栈的大小?
由于函数调用call会push CS和IP,考虑使用无穷递归,打印出起始到栈溢出时的SP区间;或者通过局部变量的地址来估算分配给线程的栈大小。 -
如何修改线程栈大小?
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. 互斥:保证指令执行的原子性
现代多处理器系统,我们面临编译器和处理器的双重复杂行为。
互斥要满足的前提条件是:能正确处理处理器乱序,宽松内存模型和编译优化。
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则睡眠等待加入写队列。
思考:一直在读的情况下,写饥饿如何处理?
增加优先级,写队列高优于读队列
6. Good coding habits
- 把程序需要满足的条件,用断言assert表达出来。
assert(cond);
cond为true,则继续往下执行
cond为false,打印退出
- 善用宏#define,预定义一些变量的检查和printf。
#ifdef LOCAL_MACHINE
#define debug(...) printf(__VA_ARGS__)
#else
#define debug(...)
#endif
- lockdep检查,发现死锁问题
存在锁嵌套时,添加加锁和解锁的行号打印,保证不存在循环等待的情况。
void lock(lock_t *lk) {
printf("LOCK %d\n", __LINE__);
}
void unlock(lock_t *lk) {
printf("UNLOCK %d\n", __LINE__);
}
- Sanitizer编译器检查
- AddressSanitizer (ASan):
• 编译器选项:使用 -fsanitize=address。
• 功能:检测Buffer (heap/stack/global) overflow, use-after-free, use-after-return, double-free, …- ThreadSanitizer (TSan):
• 编译器选项:使用 -fsanitize=thread。
• 功能:检测数据竞争和死锁。- MemorySanitizer (MSan):
• 编译器选项:使用 -fsanitize=memory。
• 功能:检测使用未初始化内存的情况。- UndefinedBehaviorSanitizer (UBSan):
• 编译器选项:使用 -fsanitize=undefined。
功能:检测和修复未定义行为,如整数溢出、空指针解引用等。
- 保护栈空间(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");
}
}