正在加载
请稍等

菜单

红楼飞雪 梦

15526773247

文章

Home linux 并发和它的管理
Home linux 并发和它的管理

linux 并发和它的管理

未分类 by

在现代 Linux 系统, 有非常多的并发源, 并且因此而来的可能竞争情况. 多个用户空间进程在运行, 它们可能以令人惊讶的方式组合存取你的代码. SMP 系统能够同时在不同处理器上执行你的代码. 内核代码是可抢占的; 你的驱动代码可
能在任何时间失去处理器, 代替它的进程可能也在你的驱动中运行. 设备中断是能够导致你的代码并发执行的异步事件. 内核也提供各种延迟代码执行的机制,例如 workqueue, tasklet, 以及定时器, 这些能够使你的代码在任何时间以一种与当前进程在做的事情无关的方式运行. 在现代的, 热插拔的世界中, 你的设备可能在你使用它们的时候轻易地消失.

避免竞争情况可能是一个令人害怕的工作. 在一个任何时候可能发生任何事的世界, 驱动程序员如何避免产生绝对的混乱? 事实证明, 大部分竞争情况可以避免,通过一些想法, 内核并发控制原语, 以及几个基本原则的应用. 我们会先从原则开始, 接着进入如何使用它们的细节中。

竞争情况来自对资源的共享存取的结果. 当 2 个执行的线路有机会操作同一个数据结构(或者硬件资源), 混合的可能性就一直存在. 因此第一个经验法则是在你设计驱动时在任何可能的时候记住避免共享的资源. 如果没有并发存取, 就没有竞争情况. 因此小心编写的内核代码应当有最小的共享. 这个想法的最明显应用是避免使用全局变量. 如果你将一个资源放在多个执行线路能够找到它的地方, 应当有一个很强的理由这样做.

事实是, 然而, 这样的共享常常是需要的. 硬件资源是, 由于它们的特性, 共享的, 软件资源也必须常常共享给多个线程. 也要记住全局变量远远不是共享数据的唯一方式; 任何时候你的代码传递一个指针给内核的其他部分, 潜在地它创造
了一个新的共享情形. 共享是生活的事实.这是资源共享的硬规则: 任何时候一个硬件或软件资源被超出一个单个执行线程共享, 并且可能存在一个线程看到那个资源的不一致时, 你必须明确地管理对那个资源的存取.

一、旗标和互斥体

旗标在计算机科学中是一个被很好理解的概念. 在它的核心, 一个旗标是一个单个整型值, 结合有一对函数, 典型地称为 P 和 V. 一个想进入临界区的进程将在相关旗标上调用 P; 如果旗标的值大于零, 这个值递减 1 并且进程继续. 相反, 如果旗标的值是 0 ( 或更小 ), 进程必须等待直到别人释放旗标. 解锁一个旗标通过调用 V 完成; 这个函数递增旗标的值, 并且, 如果需要, 唤醒等待的进程.

当旗标用作互斥 — 阻止多个进程同时在同一个临界区内运行 — 它们的值将初始化为 1. 这样的旗标在任何给定时间只能由一个单个进程或者线程持有. 以这种模式使用的旗标有时称为一个互斥锁, 就是, 当然, “互斥”的缩写. 几乎所有在 Linux 内核中发现的旗标都是用作互斥.

1.Linux 旗标实现

Linux 内核提供了一个遵守上面语义的旗标实现, 尽管术语有些不同. 为使用旗标, 内核代码必须包含 <asm/semaphore.h>. 相关的类型是 struct semaphore;实际旗标可以用几种方法来声明和初始化. 一种是直接创建一个旗标, 接着使用sema_init 来设定它:

void sema_init(struct semaphore *sem, int val);
 
这里 val 是安排给旗标的初始值.然而, 通常旗标以互斥锁的模式使用. 为使这个通用的例子更容易些, 内核提供了一套帮助函数和宏定义. 因此, 一个互斥锁可以声明和初始化, 使用下面的一种:

DECLARE_MUTEX(name);
DECLARE_MUTEX_LOCKED(name);
这里, 结果是一个旗标变量( 称为 name ), 初始化为 1 ( 使用 DECLARE_MUTEX )或者 0 (使DECLARE_MUTEX_LOCKED ). 在后一种情况, 互斥锁开始于上锁的状态; 在允许任何线程存取之前将不得不显式解锁它.如果互斥锁必须在运行时间初始化( 这是如果动态分配它的情况, 举例来说),使用下列中的一个:

void init_MUTEX(struct semaphore *sem);
void init_MUTEX_LOCKED(struct semaphore *sem);
在 Linux 世界中, P 函数称为 down — 或者这个名子的某个变体. 这里, “down”指的是这样的事实, 这个函数递减旗标的值, 并且, 也许在使调用者睡眠一会儿来等待旗标变可用之后, 给予对被保护资源的存取. 有 3 个版本的 down:

void down(struct semaphore *sem);
int down_interruptible(struct semaphore *sem);
int down_trylock(struct semaphore *sem);

 

down 递减旗标值并且等待需要的时间. down_interruptible 同样, 但是操作是可中断的. 这个可中断的版本几乎一直是你要的那个; 它允许一个在等待一个旗标的用户空间进程被用户中断. 作为一个通用的规则, 你不想使用不可中断的操作, 除非实在是没有选择. 不可中断操作是一个创建不可杀死的进程( 在 ps 中见到的可怕的 “D 状态” )和惹恼你的用户的好方法, 使用 down_interruptible需要一些格外的小心, 但是, 如果操作是可中断的, 函数返回一个非零值, 并且调用者不持有旗标. 正确的使用 down_interruptible 需要一直检查返回值并且针对性地响应.最后的版本 ( down_trylock ) 从不睡眠; 如果旗标在调用时不可用,
down_trylock 立刻返回一个非零值.一旦一个线程已经成功调用 down 各个版本中的一个, 就说它持有着旗标(或者已经”取得”或者”获得”旗标). 这个线程现在有权力存取这个旗标保护的临界区.
当这个需要互斥的操作完成时, 旗标必须被返回. V 的 Linux 对应物是 up:

void up(struct semaphore *sem);

 

一旦 up 被调用, 调用者就不再拥有旗标.如你所愿, 要求获取一个旗标的任何线程, 使用一个(且只能一个)对 up 的调用
释放它. 在错误路径中常常需要特别的小心; 如果在持有一个旗标时遇到一个错误, 旗标必须在返回错误状态给调用者之前释放旗标. 没有释放旗标是容易犯的一个错误; 这个结果( 进程挂在看来无关的地方 )可能是难于重现和跟踪的.

二、读者/写者旗标

旗标为所有调用者进行互斥, 不管每个线程可能想做什么. 然而, 很多任务分为2 种清楚的类型: 只需要读取被保护的数据结构的类型, 和必须做改变的类型.允许多个并发读者常常是可能的, 只要没有人试图做任何改变. 这样做能够显著提高性能; 只读的任务可以并行进行它们的工作而不必等待其他读者退出临界区.
Linux 内核为这种情况提供一个特殊的旗标类型称为 rwsem (或者”reader/writer semaphore”). rwsem 在驱动中的使用相对较少, 但是有时它们有用.
使用 rwsem 的代码必须包含 <linux/rwsem.h>. 读者写者旗标 的相关数据类型是 struct rw_semaphore; 一个 rwsem 必须在运行时显式初始化:

void init_rwsem(struct rw_semaphore *sem);

一个新初始化的 rwsem 对出现的下一个任务( 读者或者写者 )是可用的. 对需要只读存取的代码的接口是:

void down_read(struct rw_semaphore *sem);
int down_read_trylock(struct rw_semaphore *sem);
void up_read(struct rw_semaphore *sem);

对 down_read 的调用提供了对被保护资源的只读存取, 与其他读者可能地并发地存取. 注意 down_read 可能将调用进程置为不可中断的睡眠.down_read_trylock 如果读存取是不可用时不会等待; 如果被准予存取它返回非零, 否则是 0. 注意 down_read_trylock 的惯例不同于大部分的内核函数, 返回值 0 指示成功. 一个使用 down_read 获取的 rwsem 必须最终使用 up_read释放.
读者的接口类似:

void down_write(struct rw_semaphore *sem);
int down_write_trylock(struct rw_semaphore *sem);
void up_write(struct rw_semaphore *sem);
void downgrade_write(struct rw_semaphore *sem);

down_write, down_write_trylock, 和 up_write 全部就像它们的读者对应部分,除了, 当然, 它们提供写存取. 如果你处于这样的情况, 需要一个写者锁来做一个快速改变, 接着一个长时间的只读存取, 你可以使用 downgrade_write 在一旦你已完成改变后允许其他读者进入.
一个 rwsem 允许一个读者或者不限数目的读者来持有旗标. 写者有优先权; 当一个写者试图进入临界区, 就不会允许读者进入直到所有的写者完成了它们的工作. 这个实现可能导致读者饥饿 — 读者被长时间拒绝存取 — 如果你有大量的写者来竞争旗标. 由于这个原因, rwsem 最好用在很少请求写的时候, 并且写者只占用短时间.

三、 Completions 机制

内核编程的一个普通模式包括在当前线程之外初始化某个动作, 接着等待这个动作结束. 这个动作可能是创建一个新内核线程或者用户空间进程, 对一个存在着的进程的请求, 或者一些基于硬件的动作. 在这些情况中, 很有诱惑去使用一个
旗标来同步 2 个任务, 使用这样的代码:

struct semaphore sem;
init_MUTEX_LOCKED(&sem);
start_external_task(&sem);
down(&sem);

外部任务可以接着调用 up(??sem), 在它的工作完成时.

事实证明, 这种情况旗标不是最好的工具. 正常使用中, 试图加锁一个旗标的代码发现旗标几乎在所有时间都可用; 如果对旗标有很多竞争, 性能会受损并且加锁方案需要重新审视. 因此旗标已经对”可用”情况做了很多的优化. 当用上面展示的方法来通知任务完成, 然而, 调用 down 的线程将几乎是一直不得不等待;因此性能将受损. 旗标还可能易于处于一个( 困难的 ) 竞争情况, 如果它们表明为自动变量以这种方式使用时. 在一些情况中, 旗标可能在调用 up 的进程用完它之前消失.

这些问题引起了在 2.4.7 内核中增加了 “completion” 接口. completion 是任务使用的一个轻量级机制: 允许一个线程告诉另一个线程工作已经完成. 为使用completion, 你的代码必须包含 <linux/completion.h>. 一个 completion 可被创建, 使用:

DECLARE_COMPLETION(my_completion);

或者, 如果 completion 必须动态创建和初始化:

struct completion my_completion;
/* ... */
init_completion(&my_completion);

等待 completion 是一个简单事来调用:

void wait_for_completion(struct completion *c);

注意这个函数进行一个不可打断的等待. 如果你的代码调用wait_for_completion 并且没有人完成这个任务, 结果会是一个不可杀死的进程.
另一方面, 真正的 completion 事件可能通过调用下列之一来发出:

void complete(struct completion *c);
void complete_all(struct completion *c);

如果多于一个线程在等待同一个 completion 事件, 这 2 个函数做法不同.complete 只唤醒一个等待的线程, 而 complete_all 允许它们所有都继续. 在大部分情况下, 只有一个等待者, 这 2 个函数将产生一致的结果.
一个 completion 正常地是一个单发设备; 使用一次就放弃. 然而, 如果采取正确的措施重新使用 completion 结构是可能的. 如果没有使用 complete_all,重新使用一个 completion 结构没有任何问题, 只要对于发出什么事件没有模糊.如果你使用 complete_all, 然而, 你必须在重新使用前重新初始化 completion结构. 宏定义:

INIT_COMPLETION(struct completion c);

可用来快速进行这个初始化.
作为如何使用 completion 的一个例子, 考虑 complete 模块, 它包含在例子源码里. 这个模块使用简单的语义定义一个设备: 任何试图从一个设备读的进程将等待(使用 wait_for_completion)直到其他进程向这个设备写. 实现这个行为的代码是:

DECLARE_COMPLETION (comp);
ssize_t
complete_read (struct file *filp, char __user * buf, size_t count,
	       loff_t * pos)
{
  printk (KERN_DEBUG "process %i (%s) going to sleep\n", current->pid,
	  current->comm);
  wait_for_completion (&comp);
  printk (KERN_DEBUG "awoken %i (%s)\n", current->pid, current->comm);
  return 0;			/* EOF */
}
 
ssize_t
complete_write (struct file * filp, const char __user * buf, size_t
		count, loff_t * pos)
{
  printk (KERN_DEBUG "process %i (%s) awakening the readers...\n",
	  current->pid, current->comm);
  complete (&comp);
  return count;			/* succeed, to avoid retrial */
}

有多个进程同时从这个设备”读”是有可能的. 每个对设备的写将确切地使一个读操作完成, 但是没有办法知道会是哪个.
completion 机制的典型使用是在模块退出时与内核线程的终止一起. 在这个原型例子里, 一些驱动的内部工作是通过一个内核线程在一个 while(1) 循环中进行的. 当模块准备好被清理时, exit 函数告知线程退出并且等待结束. 为此目的,内核包含一个特殊的函数给线程使用:

void complete_and_exit(struct completion *c, long retval);

四、自旋锁

对于互斥, 旗标是一个有用的工具, 但是它们不是内核提供的唯一这样的工具.相反, 大部分加锁是由一种称为自旋锁的机制来实现. 不象旗标, 自旋锁可用在不能睡眠的代码中, 例如中断处理. 当正确地使用了, 通常自旋锁提供了比旗标更高的性能. 然而, 它们确实带来对它们用法的一套不同的限制.
自旋锁概念上简单. 一个自旋锁是一个互斥设备, 只能有 2 个值:”上锁”和”解锁”. 它常常实现为一个整数值中的一个单个位. 想获取一个特殊锁的代码测试相关的位. 如果锁是可用的, 这个”上锁”位被置位并且代码继续进入临界区. 相反, 如果这个锁已经被别人获得, 代码进入一个紧凑的循环中反复检查这个锁,直到它变为可用. 这个循环就是自旋锁的”自旋”部分.
当然, 一个自旋锁的真实实现比上面描述的复杂一点. 这个”测试并置位”操作必须以原子方式进行, 以便只有一个线程能够获得锁, 就算如果有多个进程在任何给定时间自旋. 必须小心以避免在超线程处理器上死锁 — 实现多个虚拟 CPU以共享一个单个处理器核心和缓存的芯片. 因此实际的自旋锁实现在每个 Linux支持的体系上都不同. 核心的概念在所有系统上相同, 然而, 当有对自旋锁的竞争, 等待的处理器在一个紧凑循环中执行并且不作有用的工作.

它们的特性上, 自旋锁是打算用在多处理器系统上, 尽管一个运行一个抢占式内核的单处理器工作站的行为如同 SMP, 如果只考虑到并发. 如果一个非抢占的单处理器系统进入一个锁上的自旋, 它将永远自旋; 没有其他的线程再能够获得CPU 来释放这个锁. 因此, 自旋锁在没有打开抢占的单处理器系统上的操作被优化为什么不作, 除了改变 IRQ 屏蔽状态的那些. 由于抢占, 甚至如果你从不希望你的代码在一个 SMP 系统上运行, 你仍然需要实现正确的加锁.

 

 

 

03 2016-01

 

我要 分享

 

 

本文 作者

 

相关 文章