深入Linux内核架构——锁与进程间通信

Linux作为多任务系统,当一个进程生成的数据传输到另一个进程时,或数据由多个进程共享时,或进程必须彼此等待时,或需要协调资源的使用时,应用程序必须彼此通信。

一、控制机制

1、竞态条件

几个进程在访问资源时彼此干扰的情况通常称之为竞态条件(race condition)。在对分布式应用编程时,这种情况是一个主要的问题,因为竞态条件无法通过系统的试错法检测。只有彻底研究源代码(深入了解各种可能发生的代码路径)并通过敏锐的直觉,才能找到并消除竞态条件。

2、临界区

对于竞态条件,其问题的本质是进程的执行在不应该的地方被中断,从而导致进程工作得不正确。对于此问题的解决方案不一定要求临界区不能中断,只要没有其他进程进入临界区,那么在临界区中执行的程序是可以中断的。确保几个进程不能同时改变共享值的禁止条件称为互斥。

大多数系统采用的方案是信号量(semaphore)的使用。信号量是由E. W. Dijkstra在1965年设计的。实质上,最初的信号量是受保护的特别变量,能够表示为正负整数,初始值为1。它有两个标准操作(up和down),这两个操作分别用于控制关键代码范围的进入和退出,且假定相互竞争的进程访问信号量机会均等。

在一个进程想要进入关键代码时,它调用down函数。这会将信号量的值减1,即将其设置为0,然后执行危险代码段(此时若有其他进程想进入该代码段调用down操作则会等待进入关键代码的进程完成操作)。在执行完操作之后,调用up函数将信号量的值加1,即重置为初始值。

信号量在用户层可以正常工作,原则上也可以用于解决内核内部的各种锁问题。但事实并非如此:性能是内核最首先的一个目标,虽然信号量初看起来容易实现,但其开销对内核来说过大,这也是内核中提供了许多不同的锁和同步机制的原因。

二、内核锁机制

在多处理器系统上,如果几个处理器同时处于核心态,理论上它们可以同时访问一个数据结构,刚好引发了竞态条件。因此,在第一个SMP功能的内核版本中,对该问题的处理是每次只允许一个处理器处于核心态,但这样效率不高。现在,内核使用了由锁组成的细粒度网络,用以明确保护各数据结构(如果处理器A在操作数据结构X,则处理器B可以执行任何其他的内核操作,但不能操作X)。

内核提供了各种锁选项,分别优化不同的内核数据使用模式:

原子操作:这些是最简单的锁操作。它们保证简单的操作,诸如计数器加1之类,可以不中断地原子执行,即使操作由几个汇编语句组成,也可以保证;

自旋锁:这些是最常用的锁选项,它们用于短期保护某段代码,以防止其他处理器的访问,在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态(忙等待),如果等待时间较长,则效率显然不高;

信号量:这些是用经典方法实现的,在等待信号量释放时,内核进入睡眠状态,直至被唤醒,唤醒后,内核才重新尝试获取信号量,互斥量是信号量的特例,互斥量保护的临界区,每次只能有一个用户进入;

读者/写者锁:这些锁会区分对数据结构的两种不同类型的访问,任意数目的处理器都可以对数据结构进行并发读访问,但只有一个处理器能进行写访问(在进行写访问时,读访问是无法进行的)。

1、对整数的原子操作

内核定义了atomic_t数据类型,用作对整数计数器的原子操作的基础。从内核的角度看,这些操作相当于一条汇编语句。

为使得内核中平台独立的部分能够使用原子操作,用于操纵atomic_t类型变量的操作必须由特定于体系结构的代码提供(因为内核将标准类型进行了封装,原子变量只能借助于ATOMIC_INIT宏初始化,不能用普通运算符处理)。

内核为SMP系统提供了local_t数据类型。该类型允许在单个CPU上的原子操作。为修改此类型变量,内核提供了基本上与atomic_t数据类型相同的一组函数,只是将atomic替换为local。

2、自旋锁

自旋锁用于保护短的代码段,其中只包含少量C语句,会很快执行完毕。大多数内核数据结构都有自身的自旋锁,在处理结构中的关键成员时,必须获得相应的自旋锁。

自旋锁通过spinlock_t数据结构实现,基本上可使用spin_lock和spin_unlock操纵。(自旋锁的实现与体系结构相关,几乎全是汇编语言)

自旋锁工作情况:

  • 如果内核中其他地方尚未获得lock,则由当前处理器获取。其他处理器不能再进入lock保护的代码范围;
  • 如果lock已经由另一个处理器获得,spin_lock进入一个无限循环,重复地检查lock是否已经由spin_unlock释放(自旋锁因此得名)。如果已经释放,则获得lock,并进入临界区。

自旋锁使用注意:

  • 如果获得锁之后不释放,系统将变得不可用,所有的处理器(包括获得锁的在内),迟早需要进入锁对应的临界区,它们会进入无限循环等待锁释放,但等不到,便产生了死锁;
  • 自旋锁决不应该长期持有,因为所有等待锁释放的处理器都处于不可用状态,无法用于其他工作;
  • 内核进入到由自旋锁保护的临界区时,就停用内核抢占,在启用了内核抢占的单处理器内核中,spin_lock(基本上)等价于preempt_disable,而spin_unlock则等价于preempt_enable。

3、信号量

内核使用的信号量定义如下(用户空间信号量的实现有所不同):

1 struct semaphore {
2     atomic_t count;    //count指定了可以同时处于信号量保护的临界区中进程的数目
3     int sleepers;    //sleepers指定了等待允许进入临界区的进程的数目
4     wait_queue_head_t wait;    //wait用于实现一个队列,保存所有在该信号量上睡眠的进程的task_struct
5 };

与自旋锁相比,信号量适合于保护更长的临界区,以防止并行访问。它们不应该用于保护较短的代码范围,因为竞争信号量时需要使进程睡眠和再次唤醒,代价很高。

大多数情况下,不需要使用信号量的所有功能,只是将其用作互斥量,这是一种二值信号量。

信号量工作情况:

  • 在进入临界区时,用down对使用计数器减1,在计数器为0时,其他进程不能进入临界区;
  • 在试图用down获取已经分配的信号量时,当前进程进入睡眠,并放置在与该信号量关联的等待队列上,同时,该进程被置于TASK_UNINTERRUPTIBLE状态,在等待进入临界区的过程中无法接收信号,如果信号量没有分配,则该进程可以立即获得信号量并进入到临界区,而不会进入睡眠;
  • 在退出临界区时,必须调用up,该例程负责唤醒在信号量睡眠的某个进程,该进程然后允许进入临界区,而所有其他等待的进程继续睡眠。

除了只能用于内核的互斥量之外,Linux也提供了futex(快速用户空间互斥量,fast userspacemutex),由核心态和用户状态组合而成,为用户空间进程提供了互斥量功能。

4、RCU机制

RCU(read-copy-update)是一个同步机制,该机制记录了指向共享数据结构的指针的所有使用者。在该结构将要改变时,则首先创建一个副本(或一个新的实例),在副本中修改。在所有进行读访问的使用者结束对旧副本的读取之后,指针可以替换为指向新的、修改后副本的指针(允许读写并发进行,但不对写访问之间的相互干扰提供保护)。使用RCU要求如下:

  • 对共享资源的访问在大部分时间应该是只读的,写访问应该相对很少;
  • RCU保护的代码范围内,内核不能进入睡眠状态;
  • 受保护资源必须通过指针访问。

RCU可以保护一般指针,也可以保护双链表。以一般指针为例,假定指针ptr指向一个被RCU保护的数据结构,直接反引用指针是禁止的,首先必须调用rcu_dereference(ptr),然后反引用返回的结果,此外,反引用指针并使用其结果的代码,需要用rcu_read_lock和rcu_read_unlock调用保护起来。对于双向链表,内核也是以RCU机制为基础,提供了标准函数进行保护。此外由struct hlist_head和struct hlist_node组成的散列表也可以通过RCU保护。

5、内存和优化屏障

尽管锁足以确保原子性,但对编译器和处理器优化过的代码,锁不能永远保证时序正确。与竞态条件相比,这个问题不仅影响SMP系统,也影响单处理器计算机。

内核提供了下面几个函数,可阻止处理器和编译器进行代码重排。

mb()、rmb()、wmb()将硬件内存屏障插入到代码流程中。rmb()是读访问内存屏障。它保证在屏障之后发出的任何读取操作执行之前,屏障之前发出的所有读取操作都已经完成。wmb适用于写访问,语义与rmb类似。读者应该能猜到,mb()合并了二者的语义。

barrier插入一个优化屏障。该指令告知编译器,保存在CPU寄存器中、在屏障之前有效的所有内存地址,在屏障之后都将失效。本质上,这意味着编译器在屏障之前发出的读写请求完成之前,不会处理屏障之后的任何读写请求。

CPU仍然可以重排时序!

smb_mb()、smp_rmb()、smp_wmb()相当于上述的硬件内存屏障,但只用于SMP系统。它们在单处理器系统上产生的是软件屏障。

read_barrier_depends()是一种特殊形式的读访问屏障,它会考虑读操作之间的依赖性。如果屏障之后的读请求,依赖于屏障之前执行的读请求的数据,那么编译器和硬件都不能重排这些请求。

6、读者/写者锁

通常,任意数目的进程都可以并发读取数据结构,而写访问只能限于一个进程。因此内核提供了额外的信号量和自旋锁版本,分别称之为读者/写者信号量和读者/写者自旋锁。

读者/写者自旋锁定义为rwlock_t数据类型。必须根据读写访问,以不同的方法获取锁。

进程对临界区进行读访问时,在进入和离开时需要分别执行read_lock和read_unlock,内核会允许任意数目的读进程并发访问临界区;

write_lock和write_unlock用于写访问。内核保证只有一个写进程(此时没有读进程)能够处于临界区中。

/写信号量的用法类似。所用的数据结构是struct rw_semaphore,down_read和up_read用于获取对临界区的读访问。写访问借助于down_write和up_write进行。

7、大内核锁

大内核锁(big kernel lock)可以锁定整个内核,确保没有处理器在核心态并行运行(已经过时啦)。使用lock_kernel可锁定整个内核,对应的解锁使用unlock_kernel。SMP系统和启用了内核抢占的单处理器系统如果设置了配置选项PREEMPT_BKL,则允许抢占大内核锁。

8、互斥量

尽管信号量可用于实现互斥量的功能,信号量的通用性导致的开销通常是不必要的。因此,内核包含了一个专用互斥量的独立实现,它们不依赖信号量。内核包含互斥量的两种实现:一种是经典的互斥量,另一种是用来解决优先级反转问题的实时互斥量。

1)经典的互斥量

经典互斥量的基本数据结构定义如下:

1 struct mutex {
2 /* 1: 未锁定, 0: 锁定, 负值:锁定,可能有等待者 */
3     atomic_t count;
4     spinlock_t wait_lock;
5     struct list_head wait_list;
6 };

如果互斥量未锁定,则count为1。锁定分为两种情况:如果只有一个进程在使用互斥量,则count设置为0。如果互斥量被锁定,而且有进程在等待互斥量解锁(在解锁时需要唤醒等待进程),则count为负值。这种特殊处理有助于加快代码的执行速度,因为在通常情况下,不会有进程在互斥量上等待。

定义新的互斥量:

  • 静态互斥量可以在编译时通过使用DEFINE_MUTEX产生(与DECLARE_MUTEX区分,后者是基于信号量的互斥量);
  • mutex_init在运行时动态初始化一个新的互斥量;
  • mutex_lock和mutex_unlock分别用于锁定和解锁互斥量。

(2)实时互斥量

实时互斥量是内核支持的另一种形式的互斥量,需要在编译时通过配置选项CONFIG_RT_MUTEX显式启用。与普通的互斥量相比,它们实现了优先级继承(priority inheritance),该特性可用于解决(或在最低限度上缓解)优先级反转的影响。

对于优先级反转问题,可以通过优先级继承解决。如果高优先级进程阻塞在互斥量上,该互斥量当前由低优先级进程持有,那么低优先级进程的优先级会临时提高到高优先级进程的优先级。

实时互斥量的定义非常接近于普通互斥量:

1 struct rt_mutex {
2     spinlock_t wait_lock;
3     struct plist_head wait_list;
4     struct task_struct *owner;
5 };

互斥量的所有者通过owner指定,wait_lock提供实际的保护,所有等待的进程都在wait_list中排队。与普通互斥量相比,决定性的改变是等待列表中的进程按优先级排序。在等待列表改变时,内核可相应地校正锁持有者的优先级。这需要到调度器的一个接口,可由函数rt_mutex_setprio提供。该函数更新动态优先级task_struct->prio,而普通优先级task_struct->normal_priority不变。

9、近似的per_CPU计数器

如果系统安装有大量CPU,计数器可能成为瓶颈:每次只有一个CPU可以修改其值;所有其他CPU都必须等待操作结束,才能再次访问计数器。如果计数器频繁访问,则会严重影响系统性能。

对某些计数器,没有必要时时了解其准确的数值。这种计数器的近似值与准确值,作用上没什么差别,可以利用这种情况,引入per-CPU计数器,加速SMP系统上计数器的操作。如图1所示,计数器的准确值存储在内存中某处,准确值所在内存位置之后是一个数组,每个数组项对应于系统中的一个CPU。

 

1 近似per-CPU计数器的数据结构

如果一个处理器想要修改计数器的值(加上或减去某个值n),它不会直接修改计数器的值,因为这需要防止其他的CPU访问计数器(这是一个费时的操作)。相反,所需的修改将保存到与计数器相关的数组中特定于当前CPU的数组项。(举例:,如果计数器应该加3,那么数组中对应的数组项为+3。如果同一个CPU在其他时间需要从计数器减去某个值(假定是5),它也不会对计数器直接操作,而是操作数组中特定于CPU的项:将3减去5,新值为-2。任何处理器读取计数器值时,都不是完全准确的。如果原值为15,在经过前述的操作之后应该是13,但仍然是15。如果只需要大致了解计数器的值,13也算得上是15的一个比较好的近似了。)

如果某个特定于CPU的数组元素修改后的绝对值超出某个阈值,则认为这种修改有问题,将随之修改计数器的值(这种改变很少发生)。在这种情况下,内核需要确保通过适当的锁机制来保护这次访问。

只要计数器改变适度,这种方案中读操作得到的平均值会相当接近于计数器的准确值。

per-CPU计数器如下:

1 struct percpu_counter {
2     spinlock_t lock;
3     long count;
4     long *counters;
5 };

count是计数器的准确值,lock是一个自旋锁,用于在需要准确值时保护计数器。counters数组中各数组项是特定于CPU的,该数组缓存了对计数器的操作。

10、锁竞争与细粒度锁

Linux在多CPU系统上的可伸缩性已经成为一个非常重要的目标。在对内核代码设计锁规则时,特别需要考虑这个问题。锁需要满足下面两个目的(不过二者通常很难同时实现):

必须防止对代码的并发访问,否则将导致失败;

对性能的影响必须尽可能小。

对于内核频繁使用的数据,同时满足这两个要求是非常复杂的,如果一整个数据结构都由一个锁保护,那么在内核的某个部分需要获取锁的时候,该锁已经被系统的其他部分获取的概率很高,这种情况下会出现较多的锁竞争(lock contention),该锁也会成为内核的一个热点。对此,将数据结构标识为各个独立的部分,使用多个锁来保护,这种解决方案称为细粒度锁

细粒度锁在较大的计算机上对提高可伸缩性很有好处,但也有两个弊端:

获取多个锁会增加操作的开销,特别是在较小的SMP计算机上;

在通过多个锁保护一个数据结构时,很自然会出现一个操作需要同时访问两个受保护区域的情形,因而需要同时持有多个锁,这要求必须遵守某种锁定次序,必须按序获取和释放锁,否则,仍然会导致死锁。

三、System V进程间通信

Linux使用System V(SysV)引入的机制,来支持用户进程的进程间通信和同步。

1、System V机制

System V UNIX的3种进程间通信(IPC)机制(信号量、消息队列、共享内存),都使用了全系统范围的资源,可以由几个进程同时共享。

在各个独立进程能够访问SysV IPC对象之前,IPC对象必须在系统内唯一标识。为此,每种IPC结构在创建时分配了一个号码,称为魔数。凡知道这个魔数的各个程序,都能够访问对应的结构。如果独立的应用程序需要彼此通信,则通常需要将该魔数永久地编译到程序中。

在访问IPC对象时,系统采用了基于文件访问权限的一个权限系统。每个IPC对象都有一个用户ID和一个组ID,依赖于产生IPC对象的程序在何种UID/GID之下运行。读写权限在初始化时分配。类似于普通的文件,这些控制了3种不同用户类别的访问:所有者、组、其他。

要创建一个授予所有可能访问权限的信号量(所有者、组、其他用户都有读写权限),则必须指定标志0666。

2、信号量

1)使用System V信号量

System V的信号量不再当作是用于支持原子执行预定义操作的简单类型变量,它是指一整套信号量,可以允许几个操作同时进行(用户看上去是原子的)。也可以请求只有一个信号量的信号量集合,并定义函数模拟原始信号量的简单操作。

2)数据结构

内核使用了几个数据结构来描述所有注册信号量的当前状态,并建立了一种网状结构。它们不仅负责管理信号量及其特征(值、读写权限,等等),还负责通过等待列表将信号量与等待进程关联起来。

初始的默认的IPC命名空间通过ipc_namespace的静态实例init_ipc_ns实现。每个命名空间都包含如下信息:

1 struct ipc_namespace {
2 ...
3     struct ipc_ids *ids[3];
4 /* 资源限制 */
5 ...
6 }

 

这里略去了与监视资源消耗和设置资源限制相关的很多数据结构成员(比如共享内存页的最大数目、共享内存段的最大长度、消息队列的最大数目等)。数组ids的每个元素对应于一种IPC机制:信号量、消息队列、共享内存(按顺序),每个数组项指向一个struct ipc_ids的实例,用于跟踪各类别现存的IPC对象。为防止对每个类别都需要查找对应的正确数组索引,内核提供了辅助函数msg_ids、shm_ids和sem_ids。

struct ipc_ids定义如下:

1 struct ipc_ids {
2     int in_use;    //保存了当前使用中IPC对象的数目
3     unsigned short seq;    //seq和seq_max用于连续产生用户空间IPC ID(不等同于序号)
4     unsigned short seq_max;
5     struct rw_semaphore rw_mutex;    //一个内核信号量,用于实现信号量操作,避免用户空间中的竞态条件
6     struct idr ipcs_idr;
7 };

每个IPC对象都由kern_ipc_perm的一个实例表示,每个对象都有一个内核内部ID,ipcs_idr用于将ID关联到指向对应的kern_ipc_perm实例的指针。使用中IPC对象的数目可能动态地增长和缩减,内核提供了一个类似于基数树的标准数据结构用于管理该信息。

 1 struct kern_ipc_perm
 2 {
 3     int id;
 4     key_t key;    //保存了用户程序用来标识信号量的魔数
 5     uid_t uid;        //指所有者的用户ID
 6     gid_t gid;        //指所有者的组ID
 7     uid_t cuid;    //保存了产生信号量的进程的用户ID
 8     gid_t cgid;    //保存了产生信号量的进程的组ID
 9     mode_t mode;    //保存了位掩码,指定了所有者、组、其他用户的访问权限
10     unsigned long seq;    //分配IPC对象时使用的序号
11 };

该结构不仅可用于信号量,还可以用于其他的IPC机制。该结构不足以保存信号量所需的所有信息,各进程的task_struct实例中有一个与IPC相关的成员:

1 struct task_struct {
2 ...
3 #ifdef CONFIG_SYSVIPC
4 /* ipc相关 */
5     struct sysv_sem sysvsem;
6 #endif
7 ...
8 };

只有设置了配置选项CONFIG_SYSVIPC时,SysV相关代码才会编译到内核中。sysv_sem数据结构封装了一个成员struct sem_undo_list *undo_list用于撤销信号量(用于崩溃进程修改了信号量状态的情况)。

sem_queue是另一个数据结构,用于将信号量与睡眠进程关联起来,该进程想要执行信号量操作,但目前不允许执行。

 1 struct sem_queue {
 2     struct sem_queue * next; /* 队列中下一项 */
 3     struct sem_queue ** prev; /* 队列中的前一项,对于第一项有*(q->prev) == q */
 4     struct task_struct* sleeper; /* 睡眠的进程 */
 5     struct sem_undo * undo; /* 用于撤销的结构 */
 6     int pid; /* 请求信号量操作的进程ID。 */
 7     int status; /* 操作的完成状态 */
 8     struct sem_array * sma; /* 操作的信号量数组 */
 9     int id; /* 内部信号量ID */
10     struct sembuf * sops; /* 待决操作数组 */
11     int nsops; /* 操作数目 */
12     int alter; /* 操作是否改变了数组? */
13 };

系统中每个信号量集合,都对应于sem_array数据结构的一个实例,该实例用于管理集合中的所有信号量,sem_array结构如下:

 1 struct sem_array {
 2     struct kern_ipc_perm sem_perm; /* 权限,参见ipc.h */
 3     time_t sem_otime; /* 最后一次信号量操作的时间 */
 4     time_t sem_ctime; /* 最后一次修改的时间 */
 5     struct sem *sem_base; /* 指向数组中第一个信号量的指针 */
 6     struct sem_queue *sem_pending; /* 需要处理的待决操作 */
 7     struct sem_queue **sem_pending_last; /* 上一个待决操作 */
 8     struct sem_undo *undo; /* 该数组上的撤销请求 */
 9     unsigned long sem_nsems; /* 数组中信号量的数目 */
10 };

2给出了所涉及的各个数据结构之间的相互关系。

 

2 信号量各数据结构之间的相互关系

kern_ipc_perm是用于管理IPC对象的数据结构的第一个成员,不仅对信号量是这样,消息队列和共享内存对象也是如此。

3)实现系统调用

所有对信号量的操作都使用一个名为ipc的系统调用执行。该调用不仅用于信号量,也用于操作消息队列和共享内存。其第一个参数用于将实际工作委托给其他函数。用于信号量的函数如下所示。

  • SEMCTL执行信号量操作,并由sys_semctl实现;
  • SEMGET读取信号量ID,相关的实现由sys_semget提供;
  • SEMOP和SEMTIMEDOP负责增加和减少信号量值,后者可以指定超时时间限制。

4)权限检查

IPC对象的保护机制,与普通的基于文件的对象相同。访问权限可以分别对对象的所有者、所有者所在组和所有其他用户指定(可能的权限包括读、写、执行)。函数ipcperms负责检查对任意IPC对象的某种操作是否有权限进行。

3、消息队列

进程之间通信的另一个方法是交换消息。这是使用消息队列机制完成的,其实现基于System V模型。消息队列的功能原理相对简单,如图3所示。

3 System V消息队列的功能原理

产生消息并将其写到队列的进程通常称之为发送者,而一个或多个其他进程(逻辑上称之为接收者)则从队列获取信息。各个消息包含消息正文和一个(正)数,以便在消息队列内实现几种类型的消息。接收者可以根据该数字检索消息(比如可以指定只接受编号1的消息,或接受编号不大于5的消息)。在消息已经读取后,内核将其从队列删除。即使几个进程在同一信道上监听,每个消息仍然只能由一个进程读取。

同一编号的消息按先进先出次序处理。放置在队列开始的消息将首先读取。但如果有选择地读取消息,则先进先出次序就不再适用。

消息队列也是使用前述信号量哪些数据结构实现,起始点是当前命名空间的适当的ipc_ids实例。内部的ID号形式上关联到kern_ipc_perm实例,在消息队列的实现中,需要通过类型转换获得不同的数据类型(struct msg_queue)。该结构定义如下:

 1 struct msg_queue {
 2     struct kern_ipc_perm q_perm;
 3     time_t q_stime; /* 上一次调用msgsnd发送消息的时间 */
 4     time_t q_rtime; /* 上一次调用msgrcv接收消息的时间 */
 5     time_t q_ctime; /* 上一次修改的时间 */
 6     unsigned long q_cbytes; /* 队列上当前字节数目 */
 7     unsigned long q_qnum; /* 队列中的消息数目 */
 8     unsigned long q_qbytes; /* 队列上最大字节数目 */
 9     pid_t q_lspid; /* 上一次调用msgsnd的pid */
10     pid_t q_lrpid; /* 上一次接收消息的pid */
11     struct list_head q_messages;
12     struct list_head q_receivers;
13     struct list_head q_senders;
14 };

3个标准的内核链表用于管理睡眠的发送者(q_senders)、睡眠的接收者(q_receivers)和消息本身(q_messages)。各个链表都使用独立的数据结构作为链表元素。

q_messages中的各个消息都封装在一个msg_msg实例中。

1 struct msg_msg {
2     struct list_head m_list;
3     long m_type;    //指定了消息类型,用于支持前文所述消息队列中不同的消息类型。
4     int m_ts; /* 消息正文长度 */
5     struct msg_msgseg* next;    //如果保存超过一个内存页的长消息,则需要next
6 /* 接下来是实际的消息 */
7 };

结构中没有指定存储消息自身的字段。因为每个消息都(至少)分配了一个内存页,msg_msg实例则保存在该页的起始处,剩余的空间可用于存储消息正文,如图4所示。从内存页的长度,减去msg_msg结构的长度,即可得到msg_msg页中可用于消息正文的最大字节数目。

4 内存中IPC消息的管理

消息正文紧接着该数据结构的实例之后存储。使用next,可以使消息分布到任意数目的页上。在通过消息队列通信时,发送进程和接收进程都可以进入睡眠:如果消息队列已经达到最大容量,则发送者在试图写入消息时会进入睡眠;如果队列中没有消息,那么接收者在试图获取消息时会进入睡眠。

睡眠的发送者放置在msg_queue的q_senders链表中,链表元素使用下列数据结构:

1 struct msg_sender {
2     struct list_head list;    //链表元素
3     struct task_struct* tsk;    //指向对应进程的task_struct的指针
4 };

 

 

q_receivers链表中用于保存接收进程的数据结构要稍长一点。

1 struct msg_receiver {
2     struct list_head r_list;
3     struct task_struct *r_tsk;
4     int r_mode;
5     long r_msgtype;
6     long r_maxsize;
7     struct msg_msg *volatile r_msg;
8 };

其中不仅保存了指向对应进程的task_struct的指针,还包括了对预期消息的描述,以及指向msg_msg实例的一个指针(消息可用时,该指针指定了复制数据的目标地址)。

5是消息队列所涉及数据结构之间的相互关系(忽略睡眠的发送进程链表)。

5 System V消息队列的数据结构

4、共享内存

与信号量和消息队列相比,共享内存没有本质性的不同。

  • 应用程序请求的IPC对象,可以通过魔数和当前命名空间的内核内部ID访问;
  • 对内存的访问,可能受到权限系统的限制;
  • 可以使用系统调用分配与IPC对象关联的内存,具备适当授权的所有进程,都可以访问该内存。

内核的实现采用了与信号量和消息队列非常类似的概念,相关数据结构关系如图6所示。

6 System V共享内存的数据结构

smd_ids全局变量的entries数组中保存了kern_ipc_perm和shmid_kernel的组合,以便管理IPC对象的访问权限。对每个共享内存对象都创建一个伪文件,通过shm_file连接到shmid_kernel的实例。内核使用shm_file->f_mapping指针访问地址空间对象(struct address_space),用于创建匿名映射。还需要设置所涉及各进程的页表,使得各个进程都能够访问与该IPC对象相关的内存区域。

四、其他IPC机制

SysV IPC通常只对应用程序员有意义,但对shell的用户,信号和管道更常用。

1、信号

SysV机制相比,信号是一种比较原始的通信机制,其底层概念非常简单,kill命令根据PID向进程发送信号。信号通过-s sig指定,是一个正整数,最大长度取决于处理器类型。

进程必须设置处理程序例程来处理信号。这些例程在信号发送到进程时调用(进程可以决定阻塞某些信号,但有几个信号的行为无法修改,如SIGKILL)。如果没有显式设置处理程序例程,内核则使用默认的处理程序实现。(init进程属于特例,内核会忽略发送给该进程的SIGKILL信号。)

1)实现信号处理程序

sigaction系统调用用于设置新的处理程序。如果没有为某个信号分配用户定义的处理程序函数,内核会自动设置预定义函数,提供合理的标准操作来处理相应的情况。

sigaction类型中用于描述处理程序的字段,其定义是平台相关的,但在所有体系结构上几乎都相同。

1 struct sigaction {
2     __sighandler_t sa_handler;    //一个指向内核在信号到达时调用的处理程序函数的指针
3     unsigned long sa_flags;    //包含了额外的标志,用于指定信号处理方式的一些约束
4 ...
5     sigset_t sa_mask; //包含了一个位掩码,每个比特位对应于系统中的一个信号
6 };

信号处理程序的函数原型如下:

1 typedef void __signalfn_t(int);
2 typedef __signalfn_t __user *__sighandler_t;

其参数是信号的编号,因此可以使用同一个处理程序函数处理不同的信号。

信号处理程序使用sigaction系统调用设置,该调用将借助用户定义的处理程序函数替换SIGTERM的默认处理程序。

2)实现信号管理

所有信号相关的数据都是借助于链式数据结构管理的,其入口是task_struct结构,其中包含了各个与信号相关的字段。

 1 struct task_struct {
 2 ...
 3 /* 信号处理程序 */
 4     struct signal_struct *signal;
 5     struct sighand_struct *sighand;
 6     sigset_t blocked;
 7     struct sigpending pending;
 8     unsigned long sas_ss_sp;
 9     size_t sas_ss_size;
10 ...
11 };

信号处理发生在内核中,但设置的信号处理程序是在用户状态运行,通常,信号处理程序使用所述进程在用户状态下的栈。但POSIX强制要求提供一种选项,在专门用于信号处理的栈上运行信号处理程序,这个附加的栈(必须通过用户应用程序显式分配),其地址和长度分别保存在sas_ss_sp和sas_ss_size。

用于管理设置的信号处理程序的信息的sighand_struct如下所示:

1 struct sighand_struct {
2     atomic_t count;    //保存了共享该结构实例的进程数目
3     struct k_sigaction action[_NSIG];    //保存设置的信号处理程序,_NSIG指定了可以处理的不同信号的数目
4 } ;

所有阻塞信号由task_struct的blocked成员定义,所使用的sigset_t数据类型是一个位掩码,所包含的比特位数目必须(至少)与所支持的信号数目相同,其数据结构为:

1 typedef struct {
2     unsigned long sig[_NSIG_WORDS];
3 } sigset_t;

pending是task_struct中与信号处理相关的最后一个成员。它建立了一个链表,包含所有已经引发、仍然有待内核处理的信号。它们使用了下列数据结构:

1 struct sigpending {
2     struct list_head list;    //通过双链表管理所有待决信号
3     sigset_t signal;        //位掩码,指定了仍然有待处理的所有信号的编号
4 };

7为各结构体之间的关系。

7 信号管理结构体之间关系

3)实现信号处理

内核用于实现信号处理的最重要的系统调用有kill(向进程组的所有进程发送一个信号)、tkill(向单个进程发送一个信号)、sigpending(检查是否有待决信号)、sigprocmask(操作阻塞信号的位掩码)、sigsuspend(进入睡眠,直至接收某特定信号)。

对于发送信号,不论名称如何,实际上kill和tkill基本相同,以sys_tkill为例,其代码流程图如图8所示。

8 sys_tkill代码流程图

find_task_by_vpid找到目标进程的task_struct之后,内核将检查进程是否有发送该信号所需权限的工作委托给check_kill_permission,该函数检查权限。剩余的信号处理工作则传递给specific_send_sig_info进行,如果信号被阻塞(可以用sig_ignored检查),则立即放弃处理;否则由send_signal产生一个新的sigqueue实例(使用sigqueue_cachep缓存),其中填充了信号数据,并添加到目标进程的sigpending链表;若发送陈宫,则可以使用signal_wake_up唤醒进程。

对于信号队列的处理,每次由核心态切换到用户状态时,内核都会完成此工作。处理的发起独立于特定的体系结构,此后,最终的效果就是调用do_signal函数(此处不详述)。

从时序上看,信号处理的过程如图9所示。

 

9 信号处理的执行

2、管道和套接字

管道和套接字是流行的进程间通信机制。管道使用了虚拟文件系统对象,套接字使用了各种网络函数以及虚拟文件系统。

管道是用于交换数据的连接。一个进程向管道的一端供给数据,另一个在管道另一端取出数据,供进一步处理。几个进程可以通过一系列管道连接起来。

管道是进程地址空间中的数据对象,在用fork或clone复制进程时同样会被复制。使用管道通信的程序就利用了这种特征。在exec系统调用用另一个程序替换子进程之后,两个不同的应用程序之间就建立了一条通信链路(必须把管道描述符重定向到标准输入和输出,或者调用dup系统调用,确保exec调用时不会关闭文件描述符)。

套接字对象在内核中初始化时也返回一个文件描述符,因此可以像普通文件一样处理,与管道不同之处在于它可以双向使用,还可以用于通过网络连接的远程系统通信。从用户的角度来看,同一系统上两个本地进程之间基于套接字的通信与分别处于两个不同大陆两台计算机上运行的应用程序之间的通信没有太大差别。

posted @ 2018-12-12 22:43  雾封尘  阅读(1565)  评论(2编辑  收藏  举报