操作系统之并发这点事儿

本文围绕Java多线程并发展开,介绍了多线程带来的共享内存、线程协作问题,如竞态条件、死锁等。阐述了锁、条件变量、信号量等解决方案,包括锁的多种实现及优化方式,还提及常见并发问题的解决办法,最后介绍了基于事件的并发模型及挑战。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

并发

桌子上有很多桃子,每个人看到桃子去抓取。这就是并发,他存在的问题是 不同的人会尝试去抓同一个桃子。这么导致后面的人抓不到。

可能的解决办法是大家排队一次拿桃子,这样又会降低效率。

如何又快又不出错的拿桃子,这就是问题所在。实际上,并发比这个更复杂。

线程

进程只有一个指令执行路径,也就是只有一个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函数没有锁。那是没有意义的。锁保护的就是某个共享变量,使得只有一个线程可以读写他。

最简单的机制就是一个大锁,锁住数据结构。但是这样会导致性能低,并发度低。引入多个锁(细粒度的锁,只锁数据结构中的某个成员),可以提高并发度。不过提高并发读不一定会提高性能。

评价锁

  1. 是否提供互斥?
  2. 公平性,当锁可用时,是否每一个竞争线程都有公平的机会抢到锁,更极端的是否有线程会饿死。
  3. 性能。3种场景下的考量:
    1. 没有竞争。
    2. 单CPU, 多个线程竞争
    3. 多CPU, 多个线程竞争

锁的实现

控制中断

最容易实现。因为他关闭了线程切换,当然不会有并发问题。

缺点很明显:

  1. 需要特权权限才可以关闭中断/恢复中断。这个权限显然不能开放给普通线程。
  2. 不支持多处理器
  3. 关闭中断 会导致中断丢失,可能会导致严重的系统问题
  4. 效率低。与正常的指令比,关/开中断的代码执行的比较慢。

只有有限的情况下,内核会使用控制中断的方式。

自旋锁的几种实现
测试并设置指令

不用的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; // 这个也是锁,实际线程抢占的
  
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值