文章目录
并发
桌子上有很多桃子,每个人看到桃子去抓取。这就是并发,他存在的问题是 不同的人会尝试去抓同一个桃子。这么导致后面的人抓不到。
可能的解决办法是大家排队一次拿桃子,这样又会降低效率。
如何又快又不出错的拿桃子,这就是问题所在。实际上,并发比这个更复杂。
线程
进程只有一个指令执行路径,也就是只有一个pc计数器。
线程的引入 增加了指令执行路径。每个线程有自己的指令执行路径,即PC计数器,也有自己独立的寄存器。
但是这些线程又共享地址空间,从而共享代码和数据。
类似于进程间的切换有上下文切换,线程间切换也有上下文切换。
进程是把状态保存到进程控制块 (Process Control Block, PCB)。
线程是把状态保存到线程控制块(Thread Control Block, TCB)。需要注意的是,同一个进程的线程间 本身是共享地址空间的,所以不需要切换当前使用的页表。
因为每个线程都有独立的栈,多线程的引入还影响了地址空间布局。有的应用程序会利用这个特性,譬如Java有ThtreadLocal, 在线程栈里存变量。
线程和进程一样,并不是创建了就马上运行,而是会由操作系统调度。
多线程带来的问题
共享内存
简短地说a = a +1
其实不止一个指令
线程1
register b= a #a1 加载变量a到寄存器
b = b+1 #a2 增加1
a = b #a3 寄存器写回a
线程2
register b= a #b1 加载变量a到寄存器
b = b+1 #b2 增加1
a = b #b3 寄存器写回a
当有2个线程都执行这个指令序列时,由于调度问题。并不是按a1,a2,a3 运行的。
有一种更可能是 a1, a2, b1,b2,b3(此时a已经更新), a3(此时写回a, 覆盖了b3的更新)。 不同的指令序列,会产生不同的结果。这也称为竞态条件。由于执行这段代码的多个线程可能导致竞争状态,因此我们将此段代码称为临界区。临界区是访问共享资源的代码片段,一定不能由多个线程共同执行。
要确保不出现这种情况,必须确保 指令是原子性的,才不会互相影响。
加锁 是一种方法。但是加锁又会带来急剧的性能降低。现在更倡导的 通过通信来共享内存,而不是共享内存来通信。这点Erlang是最经典的。
objdump, gdb, valgrind, purify 这些工具有助于分析底层细节。
线程协作
譬如A 线程 等待B 线程的运行结果。B线程等待C线程的结果, C又等待A的结果。这样形成一个环,导致死锁。
解决方案
锁
琐是最基础的机制,它的实现依赖于硬件和操作系统。
锁的价值: 使临界区的代码 能够像原子指令一样执行。
所以使用的锁的时候,一定得注意哪个是共享变量,哪个是临界区。
如果一个共享变量,在A函数加了锁,在B函数没有锁。那是没有意义的。锁保护的就是某个共享变量,使得只有一个线程可以读写他。
最简单的机制就是一个大锁,锁住数据结构。但是这样会导致性能低,并发度低。引入多个锁(细粒度的锁,只锁数据结构中的某个成员),可以提高并发度。不过提高并发读不一定会提高性能。
评价锁
- 是否提供互斥?
- 公平性,当锁可用时,是否每一个竞争线程都有公平的机会抢到锁,更极端的是否有线程会饿死。
- 性能。3种场景下的考量:
- 没有竞争。
- 单CPU, 多个线程竞争
- 多CPU, 多个线程竞争
锁的实现
控制中断
最容易实现。因为他关闭了线程切换,当然不会有并发问题。
缺点很明显:
- 需要特权权限才可以关闭中断/恢复中断。这个权限显然不能开放给普通线程。
- 不支持多处理器
- 关闭中断 会导致中断丢失,可能会导致严重的系统问题
- 效率低。与正常的指令比,关/开中断的代码执行的比较慢。
只有有限的情况下,内核会使用控制中断的方式。
自旋锁的几种实现
测试并设置指令
不用的cpu有不同的指令。
指令伪代码如下:
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr;
*old_ptr = new;
return old;
}
可以理解为这个函数由硬件实现,是原子性的。
自旋锁代码:
typedef struct lock_t {
int flag;
} lock_t;
void init(lock_t *lock) {
lock->flag = 0;
}
void lock(lock_t *lock) {
while(TestAndSet(lock->flag, 1)==1)
;// 这里体现了自旋。 就是一直设置flag为1, 如果之前的值是0,表明站有锁,成功,否则就是已经有线程占有锁,只能重试。
// 这里也体现了: 在单处理器上,需要抢占式的调度器,即有时钟中断,时间片用完了,切换线程。否则就会死循环了。
}
这种实现低效且没有公平性,有饿死的可能。
比较并交换
从实现自旋锁的角度看,和 测试并设置 没差别。
指令伪代码如下:
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if (actual == expected) {
*ptr = new;
}
return actual;
}
链接的加载和条件式存储指令
关键是条件式存储指令。
在存储时,只有变量的值 还是 加载时的值时,存储才会成功。这其实是给了我们一个信息:加载和设置期间是否发生过线程切换。
可能的代码实现如下:
void lock(lock_t *lock) {
while(LoadLinked(&lock->flag) || !StoreConditional(&lock->flag,1))
; //如果flag=1, 表明已有线程持有锁,重试
// 如果存储失败,说明flag被其他线程修改过,需要重视
}
获取并增加
这个有点巧妙,并能保证所有线程都能抢到锁。
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
typedef struct lock_t {
int ticket;
int turn;
} lock_t;
void lock_init(lock_t *lock) {
lock->ticket = 0;
lock->turn = 0;
}
void lock(lock_t *lock) {
int myturn = FetchAndAdd(&lock->ticket);
while(lock->turn != myturn)
; // 如果没有其他线程抢锁,turn 和 更新前的tikcet 是相等的。
}
void unlock(lock_t *lcok) {
FetchAndAdd(&lock->turn);// 释放锁。ticket-turn 可以表明同时有多少个线程在竞争锁。
}
自旋锁的问题是空耗CPU, 性能低下。如何优化呢?
优化自旋锁
释放CPU
既然发现自己没有运行的机会,就把机会让出来吧。
void lock() {
while(TestAndSet(&flag,1)==1) {
yield();// 放弃自己的运行机会,给操作系统重新调度
}
}
有点用处,但是CPU切换本身性能低下,不是很好使。
使用队列:休眠替代自旋
这个需要操作系统支持,譬如Solaris 提供park()和unpark(threadId) 用来休眠和唤醒。
typedef struct * lock_t{
int flag; // 这个也是锁,实际线程抢占的