Linux中的并发与竞态

场景:

正在运行的多个用户空间进程可能以一种令人惊讶的组合方式访问我们的代码。

我们的驱动程序代码可能在任何时候丢失对处理器的独占,而拥有处理器的进程可能正在调用我们的驱动程序代码。
内核还提供了许多可延迟代码执行的机制,比如workqueue(工作队列)、tasklet(小任务)以及timer(定时器)等。这些机制使得代码可在任何时刻执行,而不管当前进程在做什么。

避免策略:
大部分竞态可通过使用内核的并发控制原语,并应用几个基本的原理来避免。

竞态通常作为对资源的共享访问结果而产生。

仔细编写的内核代码应该具有最少的共享。
这种思想的最明显应用就是避免使用全局变量。

资源共享的硬规则:在单个执行线程之外共享硬件或软件资源时,因为另外一个线程可能产生对该资源的不一致观察,因此必须显式地管理对该资源的访问。

确保一次只有一个执行线程可操作共享资源。

当内核代码创建了一个可能和其他内核部分共享的对象时,该对象必须在还有其他组件引用自己时保持存在(并正确工作)。

在对象尚不能正确工作时,不能将其对内核可用,对这类对象的应用必须得到跟踪。

避免机制:
1. 信号量和互斥体
临界区:在任意给定的时刻,代码只能被一个线程执行。

内核为不同的需求提供了不同的原语。

应用程序开发者能够理解I/O请求通常不会立刻得到满足。

“进入休眠”:当一个Linux进程到达某个时间点,此时它不能进行任何处理时,它将进入休眠(或“阻塞”)状态,这将把处理器让给其他执行线程直到将来它能够继续完成自己的处理为止。在等待I/O完成时,进程经常会进入休眠状态。

一种锁定机制:当进程在等待对临界区的访问时,此机制可让进程进入休眠状态。

休眠可能发生在任何时刻。

为了让我们的临界区正确工作,选择使用的锁定原语必须在其他拥有这个锁并休眠的情况下工作。

但在可能出现休眠的情况下,并不是所有的锁定机制都可用。

2. completion
内核编程中常见的一种模式是,在当前线程之外初始化某个活动,然后等待该活动的结束。
这个活动可能是:创建一个新的内核线程或者新的用户空间进程、对一个已有进程的某个请求,或者某种类型的硬件动作。
可以使用信号量进行同步,但如果存在针对该信号量的严重竞争,性能将受到影响。
如果使用信号量在任务完成时进行通信,则调用down的线程几乎总是要等待,这样性能也同样会受到影响。

completion是一种轻量级的机制,它允许一个线程告诉另一个线程某个工作已经完成。


为了使用completion,代码必须包含<linux/completion.h>。

接口:
DECLARE_COMPLETION(my_completion);
struct completion my_completion;
init_completion(&my_completion);
void wait_for_completion(struct completion *c);
该函数执行一个非中断的等待,如果代码调用了wait_for_completion且没有人会完成该任务,则将产生一个不可杀的进程。
实际的completion事件可通过调用下面函数之一来触发:
void complete(struct completion *c);
complete只会唤醒一个等待线程。
void complete_all(struct completion *c);
complete_all允许唤醒所有等待线程。

下面这个宏可用来快速执行重新初始化:
INIT_COMPLETION(struct completion c);

completion机制的典型使用是模块退出时的内核线程终止。
在这种原型中,某些驱动程序的内部工作由一个内核线程在while(1)循环中完成。
当内核准备清除该模块时,exit函数会告诉该线程退出并等待completion。
为了实现这个目的,内核包含了可用于这种线程的一个特殊函数:
void complete_and_exit(struct completion *c, long retval);

3.自旋锁
自旋锁可在不能休眠的代码中使用,比如中断处理例程。
在正确使用的情况下,自旋锁通常可以提供比信号量更高的性能。

如果非抢占式的单处理器系统进入某个锁上的自旋状态,则会永远自旋下去。
非抢占式的单处理器系统上的自旋锁被优化为不作任何事情。

自旋锁原语所需要包含的文件是<linux/spinlock.h>。
实际的锁具有spinlock_t类型。
spinlock_t my_lock = SPIN_LOCK_UNLOCKED;
或者
void spin_lock(spinlock_t *lock);
void spin_unlock(spinlock_t *lock);

适用于自旋锁的核心规则是:任何拥有自旋锁的代码都必须是原子的,它不能休眠。不能因为任何原因放弃处理器,除了服务中断之外。

内核抢占的情况由自旋锁代码本身处理。
任何时候,只要内核代码拥有自旋锁,在相关处理器上的抢占就会被禁止。

在拥有锁的时候避免休眠有时很难做到:许多内核函数可以休眠(在用户空间和内核空间之间复制数据、kmalloc)

当我们编写需要在自旋锁下执行的代码时,必须注意每一个所调用的函数。
拥有自旋锁时禁止中断。(防止中断例程自旋)

自旋锁必须在可能的最短时间内拥有。

void spin_lock(spinlock_t *lock);
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
void spin_lock_irq(spinlock_t *lock);
void spin_lock_bh(spinlock_t *lock);


spin_lock_irqsave会在获得自旋锁之前禁止中断,而先前的中断状态保存在flags中。

spin_lock_bh在获得锁之前禁止软件中断,但会让硬件中断保持打开。


如果一个自旋锁可以被运行在(硬件或软件)中断上下问中的代码获得,则必须使用某个禁止中断的spin_lock形式。

如果可能在软件中断(如tasklet的形式运行的代码)中访问,则应该使用spin_lock_bh。


4. 除了锁之外的办法
构造算法以从根本上避免使用锁。(循环缓冲区)
原子变量
位操作
seqlock

5. 读取-复制-更新(read-copy-update, RCU)

也是一种高级的互斥机制。

RCU对它可以保护的数据结构做了一些限定。
它针对经常发生读取而很少写入的情形做了优化。

被保护的资源应该通过指针访问,而对这些资源的引用必须仅由原子代码拥有。在需要修改该数据结构时,写入线程首先复制,然后修改副本,之后用新的版本替换相关指针。当内核确信老的版本上没有其他引用时,就可释放老的版本。

RCU的实际使用实例可参考网络路由表。


锁陷阱
当我们创建了一个可被执行访问的对象时,应该同时定义用来控制访问的锁。
现代的内核可包含数千个锁,每个锁保护一个小的资源。


如果怀疑锁竞争导致性能下降,则可以使用lockmeter工具。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值