5锁与进程间通信
控制机制
竞态条件
竞争冒险(race hazard)又名竞态条件、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出
多进程同时访问一个变量
临界区
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待
多进程同时访问一段存在竞态条件的代码
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件
。导致竞态条件发生的代码区称作临界区
一种解决方案:信号量
信号量(semaphore)是由E. W. Dijkstra在1965年设计.
-
初始值为1
为操作信号量定义了两个标准操作:up和down。这两个操作分别用于控制关键代码范围的进入和退出 -
在一个进程想要进入关键代码时,它调用down函数。这会将信号量的值减1,即将其设置为0,下一个调用down的进程会睡眠一直等待上一个进程执行完毕
当进程在信号量上睡眠时,内核将其置于阻塞状态,且与其他在该信号量上等待的进程一同放到
一个等待列表中。 -
在执行完操作之后,调用up函数将信号量的值加1,即重置为初始值,并恢复一个在该信号量上睡眠的进程。
内核锁机制
在第一个提供了SMP功能的内核版本中,为解决竞态问题,解决方案非常简单,即每次只允许一个处理器处于核心态。令人遗憾的是,该方法因为效率不高,很快被废弃了.现在,内核使用了由锁组成的细粒度网络,来明确地保护各个数据结构
原子操作
:这些是最简单的锁操作。它们保证简单的操作,诸如计数器加1之类,可以不中断地原子执行。即使操作由几个汇编语句组成,也可以保证自旋锁
:这些是最常用的锁选项。它们用于短期保护某段代码,以防止其他处理器的访问。在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态(忙等待
)。当然,如果等待时间较长,则效率显然不高信号量
:这些是用经典方法实现的。在等待信号量释放时,进程进入睡眠
状态,直至被唤醒。唤醒后,内核才重新尝试获取信号量。互斥量是信号量的特例,互斥量保护的临界区,每次只能有一个用户进入读者/写者锁
:这些锁会区分对数据结构的两种不同类型的访问。任意数目的处理器都可以对数据结构进行并发读访问,但只有一个处理器能进行写访问。事实上,在进行写访问时,读访问是无法进行的
对整数的原子操作
//include/asm-x86/atomic_32.h
typedef struct { int counter; } atomic_t;
原子操作的实现使用了特殊的锁指令
阻止系统中其他处理器工作,直至当前处理器完成下一个操作为止。也可以使用效果相同的等价机制。
原子变量只能借助于ATOMIC_INIT
宏初始化
如果内核编译时未启用SMP支持,则上述操作的实现与普通变量一样(只遵守了atomic_t的封装),因为没有其他处理器的干扰
内核为SMP系统提供了local_t
数据类型。该类型允许在单个CPU上的原子操作。为修改此类型变量,内核提供了基本上与atomic_t数据类型相同的一组函数,只是将atomic替换为local
//include/asm-x86/local_32.h
typedef struct
{
atomic_long_t a;
} local_t;
原子变量很适合整数操作,但不适用于比特位操作
。因此每种体系结构都必须定义一组位处理操作,这些操作的工作方式也是原子的,以便在SMP系统上的各处理器之间保证一致性
//include/asm-x86/bitops_32.h
/*将位置nr的比特位置位,计数从addr开始*/
void set_bit(int nr, volatile unsigned long * addr)
/*检查指定的比特位是否置位*/
int test_bit(int nr, const volatile void * addr)
/*清除位置nr上的比特位(计数从addr开始)。*/
void clear_bit(int nr, volatile unsigned long * addr)
/*将位置nr处的比特位取反(计数从addr开始)。换言之,置位者被清除,反之亦然*/
void change_bit(int nr, volatile unsigned long * addr)
/*将一个比特位置位,并返回该比特位此前的值*/
int test_and_set_bit(int nr, volatile unsigned long * addr)
/*将一个比特位清除,并返回该比特位此前的值*/
int test_and_clear_bit(int nr, volatile unsigned long * addr)
/*将一个比特位取反,并返回该比特位此前的值。*/
int test_and_change_bit(int nr, volatile unsigned long* addr)
//非原子版本,前缀为双下划线(例如,__set_bit)
自旋锁
内核对自旋锁的说明文档:Documentation/spinlocks.txt。
自旋锁能够解决进程之间的抢占和多核引起的竞态问题,但是中断引起的竞态问题,自旋锁无法解决.忙等待消耗cpu资源,自旋锁保护的临界区的代码执行速度要快(代码短),更不能进行休眠操作
注意:
- 如果获得锁之后不释放,系统将变得不可用。所有的处理器(包括获得锁的在内),迟早需要进入锁对应的临界区。它们会进入无限循环等待锁释放,但等不到。这产生了死锁
- 自旋锁决不应该长期持有,因为所有等待锁释放的处理器都处于不可用状态
如果自旋锁保护的代码会分配内存
,那么该代码大多数时间可能工作完全正常,但少数情况下会造成失败
。当然,这种问题很难重现和调试。因此读者应该非常注意在自旋锁保护的代码中调用的函数,确保这些函数在任何情况下都不会睡眠
。
spin_lock
定义为一个原子操作
//include/linux/spinlock_types.h
/*自旋锁*/
typedef struct {
raw_spinlock_t raw_lock;
#if defined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} spinlock_t;
//include/linux/spinlock.h
/*自旋锁初始化*/
# define spin_lock_init(lock) \
do { *(lock) = SPIN_LOCK_UNLOCKED; } while (0)
//自旋锁加锁
#define spin_lock(lock) _spin_lock(lock)
//自旋锁解锁
# define spin_unlock(lock) _spin_unlock(lock)
//自旋锁尝试加锁,加锁失败时返回0,成功时非0,不会等待
#define spin_trylock(lock) __cond_lock(lock, _spin_trylock(lock))
//自旋锁加锁并停用本地cpu的中断
#define spin_lock_irqsave(lock, flags) _spin_lock_irqsave(lock, flags)
//自旋锁解锁并启用本地cpu的中断
#define spin_unlock_irqrestore(lock, flags) \
_spin_unlock_irqrestore(lock, flags)
//自旋锁加锁并停用softIRQ(软中断)
#define spin_lock_bh(lock) _spin_lock_bh(lock)
//自旋锁解锁并启用softIRQ(软中断)
#define spin_unlock_bh(lock) _spin_unlock_bh(lock)
//自旋锁尝试加锁并停用softIRQ(软中断),加锁失败时返回0,成功时非0,不会等待
#define spin_trylock_bh(lock) __cond_lock(lock, _spin_trylock_bh(lock))
内核信号量
除了只能用于内核的互斥量之外,Linux也提供了所谓的futex(快速用户空间互斥量,fast userspace mutex),由核心态和用户状态组合而成。它为用户空间进程提供了互斥量功能。但必须确保其使用和操作尽可能快速并高效.更多信息请参见手册页futex(2)
信号量
的本质还是自旋锁,又称睡眠锁,可以睡眠,解决中断屏蔽
,信号量只能用于进程,可用于保护代码长的临界区,不应该用于保护较短的代码范围,因为竞争信号量时需要使进程睡眠和再次唤醒,代价很高
//include/asm-x86/semaphore_32.h
/*信号量*/
struct semaphore {
/*可进入临界区的进程数目。count == 1用于大多数情况(此类信号量又名互斥信号量,因为它们用于实现互斥),在计数器为0时,其他进程不能进入临界区,会睡眠*/
atomic_t count;
/*等待进入临界区的进程的数目。不同于自旋锁,等待的进程会进入睡眠状态,直至信号量释放才会被唤醒。这意味着相关的CPU在同时可以执行其他任务*/
int sleepers;
/*等待队列,保存所有在该信号量上睡眠的进程的task_struct*/
wait_queue_head_t wait;
};
/*定义互斥量(二值信号量)*/
#define DECLARE_MUTEX(name) __DECLARE_SEMAPHORE_GENERIC(name,1)
/*对信号量计数器减1,计数器为0时进程进入睡眠,并放置在与该信号量关联的等待队列上.同时,该进程被置于TASK_UNINTERRUPTIBLE状态,在等待进入临界区的过程中无法接收信号*/
void down(struct semaphore * sem)
/*对信号量计数器加1,计数器为0时从等待队列上唤醒一个进程*/
void up(struct semaphore * sem)
/*对信号量计数器减1,计数器为0时进程进入睡眠,并放置在与该信号量关联的等待队列上.同时,该进程被置于TASK_INTERRUPTIBLE状态,在等待进入临界区的过程中可以接收信号.因此,在进程睡眠时可以通过信号唤醒*/
int down_interruptible(struct semaphore * sem)
/*试图获取信号量。如果失败,则进程不会进入睡眠等待信号量,而是继续正常执行。如果获取了信号量,则该函数返回false值,否则返回true。*/
int down_trylock(struct semaphore * sem)
RCU机制
Documentation/RCU
RCU(read-copy-update)一种内核同步机制,RCU的性能很好,不过对内存有一定的开销,但大多数情况下可以忽略。
- 对共享资源的访问在大部分时间应该是只读的,写访问应该相对很少
- 在RCU保护的代码范围内,内核不能进入睡眠状态
- 受保护资源必须通过指针访问。
原理
:记录指向共享数据结构的指针的所有使用者。在该结构将要改变时,则首先创建一个副本(或一个新的实例,填充适当的内容,这没什么差别),在副本中修改。在所有进行读访问的使用者结束对旧副本的读取之后,指针可以替换为指向新的、修改后副本的指针
。请注意,这种机制允许读写并发进行!
RCU对指针的保护
:
//include/linux/rcupdate.h
/*解除被rcu保护的指针p*/
#define rcu_dereference(p) ({ \
typeof(p) _________p1 = ACCESS_ONCE(p); \
smp_read_barrier_depends(); \
(_________p1); \
})
#define rcu_read_lock() \
do { \
preempt_disable(); \
__acquire(RCU); \
rcu_read_acquire(); \
} while(0)
#define rcu_read_unlock() \
do { \
rcu_read_release(); \
__release(RCU); \
preempt_enable(); \
} while(0)
//修改指针指向的对象时需要使用
#define rcu_assign_pointer(p, v) ({ \
smp_wmb(); \
(p) = (v); \
})
//kernel/rcupdate.c
/*rcu同步结构体*/
struct rcu_synchronize {
struct rcu_head head;
struct completion completion;
};
/*等待所有的读访问完成*/
void synchronize_rcu(void)
/*注册一个函数,在所有针对共享资源的读访问完成之后调用*/
void fastcall call_rcu(struct rcu_head *head,
void (*func)(struct rcu_head *rcu))
/*--------------------------------------------*/
//对rcu对象读操作的使用例子如下:
//加锁
rcu_read_lock();
//解除rcu保护的指针ptr
p = rcu_dereference(ptr);
//使用指针
if (p != NULL) {
awesome_function(p);
}
//解锁
rcu_read_unlock();
//被反引用的指针不能在rcu_read_lock()和rcu_read_unlock()保护的代码范围之外使用,也不能用于写访问
//对rcu对象修改操作的使用例子如下:
//将ptr对象改为new_ptr
struct super_duper *new_ptr = kmalloc(...);
new_ptr->meaning = xyz;
new_ptr->of = 42;
new_ptr->life = 23;
rcu_assign_pointer(ptr, new_ptr);
//如果更新可能来自内核中许多地方,那么必须使用普通的同步函数防止并发的写操作,如自旋锁。尽管RCU能保护读访问不受写访问的干扰,但它不对写访问之间的相互干扰提供防护
RCU对双链表的保护
通过RCU保护的链表,好消息是仍然可以使用标准的链表元素。只有在遍历链表、修改和删除链表元素时,必须调用标准函数的RCU变体
在标准函数之后附加_rcu后缀
//include/linux/list.h
//将新的链表元素new添加到表头为head的链表头部
void list_add_rcu(struct list_head *new, struct list_head *head)
//将新的链表元素new添加到表头为head的链表尾部
void list_add_tail_rcu(struct list_head *new,
struct list_head *head)
//从链表删除链表元素entry
void list_del_rcu(struct list_head *entry)
//将链表元素old替换为new
static inline void list_replace_rcu(struct list_head *old,
struct list_head *new)
//允许遍历链表的所有元素
#define list_for_each_rcu(pos, head)
//允许遍历链表的所有元素,对于删除链表元素也是安全的
#define list_for_each_safe_rcu(pos, n, head)
/*--------------------------------------------*/
//使用例子如下:
//加锁
rcu_read_lock();
list_for_each_rcu()
{
...
}
list_for_each_safe_rcu()
{
...
}
//解锁
rcu_read_unlock();
内存和优化屏障
指令重排
能提高代码性能,但编译器或处理器很难判定重排的结果是否确实与代码原本的意图匹配,尽管锁足以确保原子性.但对编译器和处理器优化过的代码,锁不能永远保证时序正确。与竞态条件相比,这个问题不仅影响SMP系统,也影响单处理器计算机
内核提供了下面几个函数,可阻止处理器和编译器进行代码重排
在 include/asm-x86/system_32.h
中
mb()、rmb()、wmb()
将硬件内存屏障
插入到代码流程中。rmb()是读访问内存屏障。它保证在屏障之后发出的任何读取操作执行之前,屏障之前发出的所有读取操作都已经完成。 wmb适用于写访问,语义与rmb类似。读者应该能猜到,mb()合并了二者的语义。barrier
插入一个优化屏障
。该指令告知编译器,保存在CPU寄存器中、在屏障之前有效的所有内存地址,在屏障之后都将失效。本质上,这意味着编译器在屏障之前发出的读写请求完成之前,不会处理屏障之后的任何读写请求。但CPU仍然可以重排时序!smb_mb()、smp_rmb()、smp_wmb()
相当于上述的硬件内存屏障,但只用于SMP系统。它们在单处理器系统上产生的是软件屏障。read_barrier_depends()
是一种特殊形式的读访问屏障
,它会考虑读操作之间的依赖性。如果屏障之后的读请求,依赖于屏障之前执行的读请求的数据,那么编译器和硬件都不能重排这些请求。
请注意
:上文给出的所有命令都会影响运行时的性能。这是很自然的,与开启优化时相比,停用优化后程序的速度会减慢,这也是优化代码的目的所在。
//include/linux/preempt.h
//看一下内核抢占开启关闭的实现
#define preempt_enable() \
do { \
preempt_enable_no_resched(); \
barrier(); \
preempt_check_resched(); \
} while (0)
#define preempt_disable() \
do { \
inc_preempt_count(); \
barrier(); \
} while (0)
/*--------------------------------*/
//正常
preempt_disable();
function_which_must_not_be_preempted();
preempt_enable();
//两种编译器错误重排
function_which_must_not_be_preempted();
preempt_disable();
preempt_enable();
preempt_disable();
preempt_enable();
function_which_must_not_be_preempted();
//所以加入 barrier 优化屏障后可防止被重排
读/写自旋锁
因为读操作可以并发,而写操作只能限于一个进程
内核提供了将读写访问分开的数据结构分别为读者/写者信号量和读/写自旋锁
//include/linux/spinlock_types.h
//读写自旋锁结构体
typedef struct {
raw_rwlock_t raw_lock;
#if defined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} rwlock_t;
//include/linux/spinlock.h
//内核会允许任意数目的读进程并发访问临界区
//读自旋锁加锁
#define read_lock(lock) _read_lock(lock)
# define read_unlock(lock) _read_unlock(lock)
//内核保证只有一个写进程(此时没有读进程)能够处于临界区中
//写自旋锁加锁
#define write_lock(lock) _write_lock(lock)
# define write_unlock(lock) _write_unlock(lock)
//_irqsave变体也同样可用,运作方式如同普通的自旋锁。以_bh结尾的变体也是可用的。它们会停用软件中断,但硬件中断仍然是启用的
读写信号量:
//读/写信号量的用法与读写自旋锁类似
//include/asm-x86/rwsem.h
//读写信号量
struct rw_semaphore {
signed long count;
#define RWSEM_UNLOCKED_VALUE 0x00000000
#define RWSEM_ACTIVE_BIAS 0x00000001
#define RWSEM_ACTIVE_MASK 0x0000ffff
#define RWSEM_WAITING_BIAS (-0x00010000)
#define RWSEM_ACTIVE_READ_BIAS RWSEM_ACTIVE_BIAS
#define RWSEM_ACTIVE_WRITE_BIAS (RWSEM_WAITING_BIAS + RWSEM_ACTIVE_BIAS)
spinlock_t wait_lock;
struct list_head wait_list;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};
//kernel/rwsem.c
//读信号量加锁
void __sched down_read(struct rw_semaphore *sem)
void up_read(struct rw_semaphore *sem)
//写信号量加锁
void __sched down_write(struct rw_semaphore *sem)
void up_write(struct rw_semaphore *sem)
//_trylock变体对所有命令都可用
大内核锁
大内核锁已废弃
大内核锁是内核锁遗迹之一,它可以锁定整个内核,确保没有处理器在核心态并行运行。该锁称为大内核锁(big kernel lock),通常用缩写表示,即BKL
使用lock_kernel
可锁定整个内核,对应的解锁使用unlock_kernel
BKL的一个特性是,它的锁深度也会进行计数
。这意味着在内核已经锁定时,仍然可以调用lock_kernel。对应的解锁操作(unlock_kernel)必须调用同样的次数,以解锁内核,使其他处理器能够进入
//lib/kernel_lock.c
void __lockfunc lock_kernel(void)
void __lockfunc unlock_kernel(void)
互斥量
尽管信号量可用于实现互斥量
的功能,信号量的通用性导致的开销通常是不必要的。因此,内核包含了一个专用互斥量的独立实现,它们不依赖信号量
。或确切地说,除了信号量实现的互斥量,内核还包含互斥量的两种实现
。一种是经典的互斥量
,另一种是用来解决优先级反转
问题的实时互斥量
-
经典互斥量
//include/linux/mutex.h //经典互斥量的结构体 struct mutex { /* 1: unlocked, 0: locked, negative: locked, possible waiters */ atomic_t count;//1: 未锁定, 0: 锁定, 负值:锁定,可能有等待者 spinlock_t wait_lock; struct list_head wait_list; }; //静态互斥量定义 #define DEFINE_MUTEX(mutexname) \ struct mutex mutexname = __MUTEX_INITIALIZER(mutexname) //在运行时动态初始化一个新的互斥量 # define mutex_init(mutex) \ do { \ static struct lock_class_key __key; \ \ __mutex_init((mutex), #mutex, &__key); \ } while (0) //kernel/mutex.c //互斥量加锁 void inline fastcall __sched mutex_lock(struct mutex *lock) //互斥量解锁 void fastcall __sched mutex_unlock(struct mutex *lock) int fastcall __sched mutex_trylock(struct mutex *lock)
-
实时互斥量
需要
CONFIG_RT_MUTEX
显式启用
与普通的互斥量相比,它们实现了优先级继承
(priority inheritance),该特性可用于解决(或在最低限度上缓解)优先级反转
的影响解决的问题:
考虑一种情况,系统上有两个进程运行:进程A优先级高,进程C优先级低。假定进程C已经获取了一个互斥量,正在所保护的临界区中运行,且在短时间内不打算退出。但在进程C进入临界区之后不久,进程A也试图获取保护临界区的互斥量。由于进程C已经获取该互斥量,因而进程A必须等待。这导致高优先级的进程A等待低优先级的进程C,相当于A优先级比C低了该问题可以通过优先级继承解决。如果高优先级进程阻塞在互斥量上,该互斥量当前由低优先级进程持有,那么
进程C的优先级(在我们的例子中)临时提高到进程A的优先级
。如果进程B现在开始运行,只能得到与进程A竞争情况下的CPU时间,从而理顺了优先级的问题//include/linux/rtmutex.h //实时互斥量,与普通互斥量相比,区别是等待列表中的进程按优先级排序。在等待列表改变时,内核可相应地校正锁持有者的优先级。这需要到调度器的一个接口,可由函数rt_mutex_setprio提供。该函数更新动态优先级task_struct->prio,而普通优先级task_struct->normal_priority不变。 struct rt_mutex { spinlock_t wait_lock; struct plist_head wait_list;//所有等待的进程都在wait_list中排队 struct task_struct *owner;//互斥量所有者 }; # define rt_mutex_init(mutex) __rt_mutex_init(mutex, NULL) //kernel/rtmutex.c void __sched rt_mutex_lock(struct rt_mutex *lock) void __sched rt_mutex_unlock(struct rt_mutex *lock) int __sched rt_mutex_trylock(struct rt_mutex *lock)
近似的per-CPU计数器
如果系统安装有大量CPU,计数器可能成为瓶颈:每次只有一个CPU可以修改其值;所有其他CPU都必须等待操作结束,才能再次访问计数器。如果计数器频繁访问,则会严重影响系统性能。
对某些计数器,没有必要时时了解其准确的数值。这种计数器的近似值与准确值,作用上没什么差别。可以利用这种情况,引入所谓的per-CPU计数器,来加速SMP系统上计数器的操作。基本思想如图5-1所示:计数器的准确值存储在内存中某处,准确值所在内存位置之后是一个数组,每个数组项对应于系统中的一个CPU
如果一个处理器想要修改计数器的值(加上或减去某个值n),它不会直接修改计数器的值,因为这需要防止其他的CPU访问计数器(这是一个费时的操作)。相反,所需的修改将保存到与计数器相关的数组中特定于当前CPU的数组项
。举例来说,如果计数器应该加3,那么数组中对应的数组项为+3。如果同一个CPU在其他时间需要从计数器减去某个值(假定是5),它也不会对计数器直接操作,而是操作数组中特定于CPU的项:将3减去5,新值为-2。任何处理器读取计数器值时,都不是完全准确的。如果原值为15,在经过前述的操作之后应该是13,但仍然是15。如果只需要大致了解计数器的值,13也算得上是15的一个比较好的近似了。
如果某个特定于CPU的数组元素修改后的绝对值超出某个阈值
,则认为这种修改有问题,将随之修改计数器的值
。在这种情况下,内核需要确保通过适当的锁机制来保护这次访问。由于这种改变很少发生,因此锁操作的代价将不那么重要了。
只要计数器改变适度,这种方案中读操作得到的平均值会相当接近于计数器的准确值
//include/linux/percpu_counter.h
//近似的per-CPU计数器
struct percpu_counter {
spinlock_t lock;//自旋锁,用于在需要准确值时保护计数器
s64 count;//计数器的准确值
s32 *counters;//counters数组中各数组项是特定于CPU的,该数组缓存了对应CPU对计数器的操作
};
//触发计数器修改的阈值定义,触发计数器修改的阈值依赖于系统中CPU的数目
#if NR_CPUS >= 16
#define FBC_BATCH (NR_CPUS*2)
#else
#define FBC_BATCH (NR_CPUS*4)
#endif
//用于对计数器增加或减少指定的值。如果积累的改变超过 FBC_BATCH 阈值,则修改计数器的准确值
void percpu_counter_add(struct percpu_counter *fbc, s64 amount)
/*percpu计数器-1*/
void percpu_counter_dec(struct percpu_counter *fbc)
//计算计数器的准确值
s64 percpu_counter_sum(struct percpu_counter *fbc)
//将计数器设置为指定值
void percpu_counter_set(struct percpu_counter *fbc, s64 amount);
/*percpu计数器+1*/
void percpu_counter_inc(struct percpu_counter *fbc)
//读取计数器的当前值,而不考虑各个CPU所进行的改动
s64 percpu_counter_read(struct percpu_counter *fbc)
锁竞争与细粒度锁
考虑一个经常访问的非常重要的数据结构,内存管理子系统、网络和内核许多其他部分都包含了该结构。如果整个数据结构(甚至于更糟糕的情形,多个数据结构、整个驱动程序或整个子系统)由一个锁保护,那么在内核的某个部分需要获取锁的时候,该锁已经被系统其他部分获取的概率是很高的。 在这种情况下会出现较多的锁竞争(lock contention),该锁会成为内核的一个热点(hotspot)。为补救这种情况,通常需要标识数据结构中各个独立的部分,使用多个锁来保护结构的成员。这种解决方案称之为细粒度锁
。尽管这种方法在较大的计算机上对提高可伸缩性很有好处,但它会引发其他问题。
- 获取多个锁会增加操作的开销,特别是在较小的SMP计算机上。
- 在通过多个锁保护一个数据结构时,很自然会出现一个操作需要同时访问两个受保护区域的情形,因而需要同时持有多个锁。这要求必须遵守某种锁定次序,必须按序获取和释放锁。否则,仍然会导致死锁!由于内核中的各个代码路径复杂而交错,所以很难保证所有情形都正确。
因而,通过细粒度锁实现良好的可伸缩性,同时避免死锁,是内核当前首要的挑战之一
System V进程间通信
Linux使用System V(SysV)引入的机制,来支持用户进程的进程间通信和同步。内核通过系统调用提供了各种函数,使得用户库(通常是C标准库)能够实现所需的操作。
除了信号量
之外,SysV的进程间通信方案还包括消息队列
和共享内存
System V机制
System V UNIX的3种进程间通信(IPC)机制(信号量、消息队列、共享内存)反映了3种相去甚远的概念,不过三者却有一个共同点
。它们都使用了全系统范围的资源,可以由几个进程同时共享
。对于IPC机制而言,这看起来似乎是合理的,但不应该视作理所当然。举例来说,该机制最初的设计目标,可能只是为了让程序的各个线程或fork产生的结构能够访问共享的SysV对象。
在各个独立进程能够访问SysV IPC对象之前,IPC对象必须在系统内唯一标识
。为此,每种IPC结构在创建时分配了一个号码
。凡知道这个魔数
的各个程序,都能够访问对应的结构。如果独立的应用程序需要彼此通信,则通常需要将该魔数永久地编译到程序中。一种备选方案是动态地产生一个保证唯一的魔数(静态分配的号码无法保证唯一)。标准库提供了几个完成此工作的函数(详细信息请参见相关的系统程序设计手册)。
在访问IPC对象时,系统采用了基于文件访问权限的一个权限系统。每个IPC对象都有一个用户ID和一个组ID,依赖于产生IPC对象的程序在何种UID/GID之下运行。读写权限在初始化时分配。
类似于普通的文件,这些控制了3种不同用户类别的访问:所有者、组、其他。这些工作具体如何完成,详细信息请参考对应的系统程序设计手册。
要创建一个授予所有可能访问权限的信号量(所有者、组、其他用户都有读写权限),则必须指定标志0666。
信号量集
System V信号量集在sem/sem.c
实现,对应的头文件是<sem.h>
。这种信号量集与上文讲述的内核信号量没有任何关系
-
使用System V信号量
System V的信号量接口决不直观,因为信号量的概念已经远超其实际定义了。信号量不再当作是用于支持原子执行预定义操作的简单类型变量。相反,一个System V信号量现在是指一整套信号量,可以允许几个操作同时进行(尽管用户看上去它们是原子的)资源不够的进程会休眠等待信号量释放
应用层使用步骤
:- 定义常数作为魔数数,来创建一个新的信号量集,在系统内建立唯一标识。semget函数
- 初始化信号量集中的信号量值。semctl函数
- 修改信号量的值。semop函数,相当于down,up的操作
- 执行临界区代码
- 修改信号量的值。semop函数,相当于down,up的操作
- 使用完毕删除信号量集。semctl函数
-
数据结构
内核使用了几个数据结构来描述所有注册信号量的当前状态,并建立了一种网状结构。它们不仅负责管理信号量及其特征(值、读写权限,等等),还负责通过等待列表将信号量与等待进程关联起来。
从内核版本2.6.19开始,IPC机制已经能够意识到命名空间的存在.但管理IPC命名空间比较简单,因为它们之间没有层次关系。给定的进程属于task_struct->nsproxy->ipc_ns
指向的命名空间,初始的默认命名空间通过ipc_namespace
的静态实例init_ipc_ns
实现。每个命名空间都包含如下信息://include/linux/ipc.h struct ipc_namespace { struct ipc_ids *ids[3];//每个数组成员对应一种IPC机制,信号量集(IPC_SEM_IDS),消息队列(IPC_MSG_IDS),共享内存(IPC_SHM_IDS) } //ipc/util.h #define IPC_SEM_IDS 0 #define IPC_MSG_IDS 1 #define IPC_SHM_IDS 2 //ipc/sem.c //获取ipc命名空间中的信号量集 #define sem_ids(ns) (*((ns)->ids[IPC_SEM_IDS])) //ipc/msg.c //获取ipc命名空间中的消息队列 #define msg_ids(ns) (*((ns)->ids[IPC_MSG_IDS])) //ipc/shm.c //获取ipc命名空间中的共享内存 #define shm_ids(ns) (*((ns)->ids[IPC_SHM_IDS]))
相关文档可以查看msgget(2)、shmget(2)、semget(2)的手册页
//ipc/util.h //IPC机制,可以表示消息队列或共享内存或信号量 struct ipc_ids { int in_use;//保存了当前使用中IPC对象的数目 /*seq和seq_max用于连续产生用户空间IPC ID。但要注意,ID不等同于序号。内核通过ID来标识IPC对象,ID按资源类型管理,即一个ID用于消息队列,一个用于信号量,一个用于共享内存对象。每次创建新的IPC对象时,序号加1(自动进行回绕,即到达最大值自动变为0)。用户层可见的ID由s * SEQ_MULTIPLIER + i给出,其中s是当前序号,i是内核内部的ID。SEQ_MULTIPLIER设置为IPC对象的上限。如果重用了内部ID,仍然会产生不同的用户空间ID,因为序号不会重用。在用户层传递了一个陈旧的ID时,这种做法最小化了使用错误资源的风险。*/ unsigned short seq; unsigned short seq_max; struct rw_semaphore rw_mutex;//内核的读写信号量。它用于实现信号量操作,避免用户空间中的竞态条件。该互斥量有效地保护了包含信号量值的数据结构 struct idr ipcs_idr;//redix树结构,树的结点数据为kern_ipc_perm,通过id可以找到对应位置的结点,kern_ipc_perm结构为3种ipc结构的第一个成员,所以可以转成3种ipc结构,通过这种方式同时管理三种结构 }; //include/linux/ipc.h //ipc对象,保存了有关信号量“所有者”和访问权限的有关信息 struct kern_ipc_perm { spinlock_t lock; int deleted; int id;//内核内部的ID key_t key;//保存了用户程序用来标识信号量的魔数 uid_t uid;//所有者的用户ID gid_t gid;//所有者的组ID uid_t cuid;//产生信号量的进程的用户ID gid_t cgid;//产生信号量的进程的组ID mode_t mode; //保存了位图,指定了所有者、组、其他用户的访问权限 unsigned long seq;//序号,在分配IPC对象时使用 void *security; }; //include/linux/sched.h //上述数据结构不足以保存信号量所需的所有信息。各进程的task_struct实例中有一个与IPC相关的成员,只有设置了配置选项CONFIG_SYSVIPC时,SysV相关代码才会编译到内核中 struct task_struct { ... #ifdef CONFIG_SYSVIPC /* ipc相关 */ struct sysv_sem sysvsem; #endif ... }; //include/linux/sem.h struct sysv_sem { //用于撤销信号量。如果进程在修改信号量之后崩溃,保存在该列表中的信息可用于将信号量的状态回复到修改之前。当崩溃进程修改了信号量状态之后,可能有等待该信号量的进程无法唤醒,该机制在这种情况下很有用。通过(使用撤销列表中的信息)撤销这些操作,信号量可以恢复到一致状态,防止死锁 struct sem_undo_list *undo_list; }; //用于将信号量与睡眠进程关联起来,该进程想要执行信号量操作,但目前不允许执行。换句话说,信号量的待处理操作列表中,每一项都是该数据结构的实例 struct sem_queue { struct sem_queue * next; /* next entry in the queue *///队列下一项 struct sem_queue ** prev; /* previous entry in the queue, *(q->prev) == q *///队列前一项 struct task_struct* sleeper; /* this process *///指向等待执行信号量操作进程的task_struct实例 struct sem_undo * undo; /* undo structure */ int pid; /* process id of requesting process *///等待进程的PID int status; /* completion status of operation */ struct sem_array * sma; /* semaphore array for operations *///保存了一个指针,指向用于管理信号量状态的数据结构的实例 int id; /* internal sem id *///内核内部的信号量ID struct sembuf * sops; /* array of pending operations *///指针,指向保存对信号量的操作的数组。操作数目(即,数组的长度)在nsops中定义 int nsops; /* number of operations */ int alter; /* does the operation alter the array? *///表明操作是否修改信号量的值(例如,状态查询不改变值) }; /*应用层semop函数的参数,用于描述对信号量的操作*/ struct sembuf { unsigned short sem_num; /* semaphore index in array *///要操作的信号量在数组中的索引 short sem_op; /* semaphore operation *///信号量操作,信号量加或减某个值 short sem_flg; /* operation flags *///操作标志 }; //系统中的每个信号量集合,都对应于该数据结构的一个实例。该实例用于管理集合中的所有信号量 struct sem_array { struct kern_ipc_perm sem_perm; /* permissions .. see ipc.h *///信号量访问权限,该成员必须位于结构的起始处,以便使用某种技巧,这涉及用于管理所有信号量集合的ipc_ids->entries数组。由于该数组中各个项指向的内存区域都分配了足够的内存,不仅可以表示kern_ipc_perm,而且也能表示sem_array,因此内核可以通过类型转换在两种表示之间切换。该技巧也用于其他的SysV IPC对象 time_t sem_otime; /* last semop time *///指定了上一次访问信号量的时间,单位为jiffies(访问包括信息查询在内) time_t sem_ctime; /* last change time *///指定了上次修改信号量值的时间 struct sem *sem_base; /* ptr to first semaphore in array *///数组,每个数组项描述了集合中的一个信号量。其中保存了当前的信号量值和上一次访问它的进程的PID struct sem_queue *sem_pending; /* pending operations to be processed *///指向待处理信号量操作的链表。该链表由sem_queue实例组成,指向链表的起始 struct sem_queue **sem_pending_last; /* last pending operation *///用于快速访问该链表的最后一个元素 struct sem_undo *undo; /* undo requests on this array */ unsigned long sem_nsems; /* no. of semaphores in array *///指定了一个用户信号量集合中信号量的数目 }; struct sem { int semval; /* current value *///当前值 int sempid; /* pid of last operation *///上一次操作进程的PID };
从当前命名空间获得
sem_ids
实例开始,内核通过ipcs_idr
找到ID到指针的映射,在其中查找所需的kern_ipc_perm
实例。kern_ipc_perm
项可以转换为sem_array
的实例。信号量的当前状态需要通过与另外两个结构的联系获取 -
系统调用的实现
所有对信号量的操作都使用一个名为ipc的系统调用
执行。该调用不仅用于信号量,也用于操作消息队列和共享内存。其第一个参数用于将实际工作委托给其他函数。用于信号量的函数如下所示//arch/x86/kernel/sys_i386_32.c asmlinkage int sys_ipc (uint call, int first, int second, int third, void __user *ptr, long fifth) //include/linux/ipc.h /*sys_ipc系统调用第一个参数的值*/ #define SEMOP 1/*增加和减少信号量值*/ #define SEMGET 2/*读取信号量ID,相关的实现由sys_semget提供*/ #define SEMCTL 3/*执行信号量操作,并由sys_semctl实现*/ #define SEMTIMEDOP 4/*增加和减少信号量值,可以指定超时时间限制*/ #define MSGSND 11 #define MSGRCV 12 #define MSGGET 13 #define MSGCTL 14 #define SHMAT 21 #define SHMDT 22 #define SHMGET 23 #define SHMCTL 24
- sys_ipc
- sys_semtimedop
- sys_semget
- sys_semctl
- sys_msgsnd
- sys_msgrcv
- sys_msgget
- sys_msgctl
- do_shmat
- sys_shmdt
- sys_shmget
- sys_shmctl
- sys_ipc
-
权限检查
IPC对象的保护机制,与普通的基于文件的对象相同。访问权限可以分别对对象的所有者、所有者所在组和所有其他用户指定。此外,可能的权限包括读、写、执行。ipcperms负责检查对任意IPC对象的某种操作是否有权限进行//ipc/util.c //ipc权限检查 int ipcperms (struct kern_ipc_perm *ipcp, short flag)
消息队列
进程之间通信的另一个方法是交换消息。这是使用消息队列机制完成的,其实现基于System V模型。就涉及的数据结构而言,消息队列和信号量有某些共同点。消息队列的功能原理相对简单
产生消息并将其写到队列的进程通常称之为发送者,而一个或多个其他进程(逻辑上称之为接收者)则从队列获取信息。各个消息包含消息正文和一个(正)数
,以便在消息队列内实现几种类型的消息。接收者可以根据该数字检索消息
,例如,可以指定只接受编号1的消息,或接受编号不大于5的消息。在消息已经读取后,内核将其从队列删除。即使几个进程在同一信道上监听,每个消息仍然只能由一个进程读取。
同一编号的消息按先进先出
次序处理。放置在队列开始的消息将首先读取。但如果有选择地读取消息,则先进先出次序就不再适用。
发送者和接收者通过消息队列通信时,无需同时运行
。例如,发送进程可以打开一个队列,写入消息,然后结束工作。接收进程在发送者结束之后启动,仍然可以访问队列并(根据消息编号)获取消息。 中间的一段时间内,消息由内核维护
内核对消息队列的实现与信号量相同,起始点是当前命名空间的适当的ipc_ids实例.同样,内部的ID号形式上关联到kern_ipc_perm
实例,在消息队列的实现中,需要通过类型转换获得不同的数据类型(struct msg_queue)
。
//include/linux/msg.h
//IPC 消息队列
struct msg_queue {
struct kern_ipc_perm q_perm;
time_t q_stime; /* last msgsnd time *///上一次调用msgsnd发送消息的时间
time_t q_rtime; /* last msgrcv time *///上一次调用msgrcv接收消息的时间
time_t q_ctime; /* last change time *///上一次修改的时间(指修改队列的属性)
unsigned long q_cbytes; /* current number of bytes on queue *///队列中当前用于消息的字节数目
unsigned long q_qnum; /* number of messages in queue *///队列中消息的数目
unsigned long q_qbytes; /* max number of bytes on queue *///队列中可能用于消息的字节的最大数目
pid_t q_lspid; /* pid of last msgsnd *///上一个发送进程的PID
pid_t q_lrpid; /* last receive pid *///上一个接收进程的PID
//3个标准的内核链表用于管理睡眠的发送者(q_senders)、睡眠的接收者(q_receivers)和消息本身(q_messages)。各个链表都使用独立的数据结构作为链表元素.q_messages中的各个消息都封装在一个msg_msg实例中
struct list_head q_messages;//消息链表头,链表元素为 msg_msg
struct list_head q_receivers;//睡眠接收者链表头,即消息空后继续接收,进程睡眠,链表元素为 struct msg_receiver
struct list_head q_senders;//睡眠的发送者链表头,即消息队列满了以后继续发送,该发送者睡眠,链表元素为 struct msg_sender
};
//消息队列消息结构体
struct msg_msg {
struct list_head m_list; //用作连接各个消息的链表元素,其他的成员用于管理消息自身,链表头为 msg_queue.q_messages
long m_type; //指定了消息类型,用于支持前文所述消息队列中不同的消息类型
int m_ts; /* message text size *///指定了消息正文长度,按字节计算
struct msg_msgseg* next;//如果保存超过一个内存页的长消息,则需要next,该指针指向页开始处的msg_msgseg实例
void *security;
/* the actual message follows immediately */
};
结构中没有指定存储消息自身的字段。因为每个消息都(至少)分配了一个内存页,msg_msg实例则保存在该页的起始处,剩余的空间可用于存储消息正文
从内存页的长度,减去msg_msg
结构的长度,即可得到msg_msg页中可用于消息正文的最大字节数目
//ipc/msgutil.c
//每个消息至少分配了一个内存页,内存页起始为消息结构体msg_msg,从内存页的长度,减去msg_msg结构的长度,即可得到msg_msg页中可用于消息正文的最大字节数目
#define DATALEN_MSG (PAGE_SIZE-sizeof(struct msg_msg))
#define DATALEN_SEG (PAGE_SIZE-sizeof(struct msg_msgseg))
更长的消息必须借助于next指针,分布在几个页中。该指针指向页开始处的msg_msgseq
实例
//ipc/msgutil.c
//消息队列内容超过一页时next指针指向的结构体,在下一页的起始保存该结构体,该页后面的部分为消息内容
struct msg_msgseg {
struct msg_msgseg* next;
/* the next part of the message follows immediately */
};
同样,消息正文紧接着该数据结构的实例之后存储。使用next,可以使消息分布到任意数目的页上
在通过消息队列通信时,发送进程和接收进程都可以进入睡眠:如果消息队列已经达到最大容量,则发送者在试图写入消息时会进入睡眠;如果队列中没有消息,那么接收者在试图获取消息时会进入睡眠
睡眠的发送者放置在msg_queue
的q_senders
链表中
//ipc/msg.c
//IPC消息队列睡眠发送者结构体
struct msg_sender {
struct list_head list;//链表元素,链表头为 msg_queue.q_senders
struct task_struct *tsk;//指向对应进程的task_struct的指针。这里不需要额外的信息,因为发送进程是在sys_msgsnd系统调用期间进入睡眠,也可能是通过sys_ipc系统调用进入睡眠。后者也可以用于发送消息,并在唤醒后自动重试发送操作
};
//IPC消息队列睡眠接收者结构体
struct msg_receiver {
struct list_head r_list;//链表元素,链表头为 msg_queue.q_receivers
struct task_struct *r_tsk;//指向对应进程的task_struct的指针
int r_mode;
long r_msgtype;//消息类型
long r_maxsize;
struct msg_msg *volatile r_msg;//指向要接收的消息
};
共享内存
从用户和内核的角度来看,它的实现使用了与上述两种机制类似的结构。与信号量和消息队列相比,共享内存没有本质性的不同
- 应用程序请求的IPC对象,可以通过魔数和当前命名空间的内核内部ID访问
- 对内存的访问,可能受到权限系统的限制
- 可以使用系统调用分配与IPC对象关联的内存,具备适当授权的所有进程,都可以访问该内存
同样,在smd_ids全局变量的entries数组中保存了kern_ipc_perm和shmid_kernel的组合,以便管理IPC对象的访问权限。对每个共享内存对象都创建一个伪文件,通过shm_file连接到shmid_kernel的实例。内核使用shm_file->f_mapping指针访问地址空间对象(struct address_space),用于创建匿名映射。还需要设置所涉及各进程的页表,使得各个进程都能够访问与该IPC对象相关的内存区域
其他IPC机制
除了System V UNIX采用的IPC机制之外,进程之间还有其他传统的方法可用于交换消息和数据。SysV IPC通常只对应用程序员有意义,但几乎所有使用过shell的用户,都会知道信号和管道
信号
与SysV机制相比,信号是一种比较原始的通信机制。尽管提供的选项较少,但是它们非常有用。其底层概念非常简单,kill命令根据PID向进程发送信号。信号通过-s sig指定,是一个正整数,最大长度取决于处理器类型。该命令有两种最常用的变体:一种是kill不指定信号,实际上是要求进程结束(进程可以忽略该信号);另一种是kill -9,等价于强制杀死进程。
过去,32位系统最多支持32个信号,该限制现在已经提高了,kill手册页上列出的所有信号都已经支持。不过,经典的信号占用了信号列表中前32个位置。接下来是针对实时进程引入的新信号。
进程必须设置处理程序例程来处理信号。这些例程在信号发送到进程时调用(但有几个信号的行为无法修改,如SIGKILL)。如果没有显式设置处理程序例程,内核则使用默认的处理程序实现。
信号引入了几种特性。进程可以决定阻塞特定的信号(有时称之为信号屏蔽)。如果发生这种情况,会一直忽略该信号,直至进程决定解除屏蔽。因而,进程是否能感知到发送的信号,是不能保证的。在信号被阻塞时,内核将其放置到待处理列表上。如果同一个信号被阻塞多次,则在待处理列表中只放置一次
。不管发送了多少相同的信号,在进程删除阻塞之后,都只会接收到一个信号
SIGKILL信号无法阻塞
,也不能通过特定于进程的处理程序处理。之所以不能修改该信号的行为,是因为它是从系统删除失控进程的最后手段。它与SIGTERM信号
不同,后者可以通过用户定义的信号处理程序处理,实际上只是向进程发出的一个客气的请求,要求进程尽快停止工作而已
。如果已经为该信号设置了处理程序,那么程序就有机会保存数据或询问用户是否确实想要退出程序。SIGKILL不会提供这种机会,因为内核会立即强行终止进程。
init进程属于特例。内核会忽略发送给该进程的SIGKILL信号。因为该进程对整个系统尤其重要,不能强制结束该进程,即使无意结束也不行
-
信号处理例子
//include/asm-x86/signal.h //描述信号处理程序的字段,平台相关 struct sigaction { __sighandler_t sa_handler;//指向内核在信号到达时调用的处理程序函数,参数是信号的编号 unsigned long sa_flags;//包含了额外的标志,用于指定信号处理方式的一些约束,这些可以参考各种系统程序设计手册 __sigrestore_t sa_restorer; sigset_t sa_mask; /* mask last for extensibility *///包含了一个位图,每个比特位对应于系统中的一个信号。它用于在处理程序例程执行期间阻塞其他信号。在例程结束后,内核会重置其值,回复到信号处理之前的原值 }; //include/asm-generic/signal.h typedef void __signalfn_t(int); typedef __signalfn_t __user *__sighandler_t; //arch/x86/kernel/signal_32.c //信号处理程序系统调用,该函数用用户定义的处理程序函数替换了默认的处理程序 asmlinkage int sys_sigaction(int sig, const struct old_sigaction __user *act, struct old_sigaction __user *oact) //进程休眠等待信号到来 asmlinkage int sys_sigsuspend(int history0, int history1, old_sigset_t mask)
-
信号处理的实现原理
//include/linux/sched.h //进程结构体 struct task_struct { /*信号处理程序*/ //会话和进程组ID保存在用于信号处理的结构中,>signal->__session表示全局SID,而全局PGID则保存在task_struct->signal->__pgrp。辅助函数set_task_session和set_task_pgrp可用于修改这些值 struct signal_struct *signal; struct sighand_struct *sighand;//用于管理设置的信号处理程序的信息 //阻塞信号的位图,进程收到阻塞的信号会一直保存在待处理的位图(pending)中,但不会被处理,直到该位图中阻塞被去除 sigset_t blocked, real_blocked; sigset_t saved_sigmask; /* To be restored with TIF_RESTORE_SIGMASK */ struct sigpending pending;//一个链表头和待处理信号位图,位图中存着待处理的信号编号,信号编号的信息存在链表中,发送信号时将信号存入目标进程的该结构中 //通常,信号处理程序使用所述进程在用户状态下的栈。但POSIX强制要求提供一种选项,在专门用于信号处理的栈上运行信号处理程序(使用sigaltstack系统调用)。这个附加的栈(必须通过用户应用程序显式分配),其地址和长度分别保存在sas_ss_sp和sas_ss_size unsigned long sas_ss_sp;//专门用于信号处理的栈地址 size_t sas_ss_size;//专门用于信号处理的栈长度 int (*notifier)(void *priv);//信号通知者函数,收到信号时执行该回调函数,返回0时屏蔽此信号 void *notifier_data;//notifier函数的参数 }; //include/asm-x86/signal.h #define _NSIG 64 #ifdef __i386__ # define _NSIG_BPW 32 #else # define _NSIG_BPW 64 #endif #define _NSIG_WORDS (_NSIG / _NSIG_BPW) //位图,保存信号 typedef struct { unsigned long sig[_NSIG_WORDS]; } sigset_t; //include/linux/signal.h //保存待处理信号 struct sigpending { struct list_head list;//链表头,链表元素的类型是 sigqueue,保存着信号的信息 sigset_t signal;//位图,指定了仍然有待处理的所有信号的编号,信号的信息在上面的链表中 }; //待处理信号队列元素 struct sigqueue { struct list_head list;//链表元素,链表头为 sigpending.list int flags; siginfo_t info;//包含有关待处理信号的更多详细信息 struct user_struct *user; }; //include/asm-generic/siginfo.h //信号的详细信息 typedef struct siginfo { int si_signo;//信号编号 int si_errno;//为非零值,表示信号由错误引发;否则其值为0 int si_code;//表示信号来源的详细信息。我们只对用户信号(SI_USER)和内核产生的信号(SI_KERNEL)之间的区别感兴趣 union { int _pad[SI_PAD_SIZE]; /* kill() */ struct {...} _kill; /* POSIX.1b timers */ struct {...} _timer; /* POSIX.1b signals */ struct {...} _rt; /* SIGCHLD */ struct {...} _sigchld; /* SIGILL, SIGFPE, SIGSEGV, SIGBUS */ struct {...} _sigfault; /* SIGPOLL */ struct {...} _sigpoll; } _sifields;//内核处理某些信号所需的附加信息保存在_sifield联合中。例如,_sigfault包含了引发信号的指令的用户空间地址 } siginfo_t;
尽管信号处理发生在内核中,但设置的信号处理程序是在用户状态运行,否则很容易向内核引入恶意或有缺陷的代码,从而破坏系统安全机制。通常,信号处理程序使用所述进程在用户状态下的栈。但POSIX强制要求提供一种选项,在专门用于信号处理的栈上运行信号处理程序(使用
sigaltstack
系统调用)。这个附加的栈(必须通过用户应用程序显式分配),其地址和长度分别保存在sas_ss_sp和sas_ss_size。//include/linux/sched.h //用于管理设置的信号处理程序的信息 struct sighand_struct { atomic_t count;//保存了共享该结构实例的进程数目。第2章讲过,clone操作可以指定父子进程共享同一个信号处理程序,这种情况下无需复制该数据结构。 struct k_sigaction action[_NSIG];//数组,存着信号处理程序,应用层可以传递自定义的信号处理程序,数组下标对应信号编号,但有几个特殊值,SIG_DFL表示使用默认的信号处理程序,SIG_IGN表示忽略信号,SIG_ERR表示错误,其他值为正常的信号处理程序 spinlock_t siglock; wait_queue_head_t signalfd_wqh; }; //include/asm-x86/signal.h /*内核信号属性*/ struct k_sigaction { struct sigaction sa; };
如果没有为信号设置用户定义的处理程序例程(这意味着使用默认的例程),则sa.sa_handler设置为SIG_DFL。在这种情况下,内核根据信号类型从下面4个标准操作中择一执行
- 忽略:什么都不做。
- 结束:结束进程或进程组。
- 停止:将进程置于TASK_STOPPED状态。
- 内存转储:创建地址空间的内存转储,并写入内存转储文件供进一步处理(例如,由调试器查看)。
下表给出了各种信号分配的默认处理程序。对应的信息可以从
<signal.h>
中定义的宏SIG_KERNEL_ONLY_MASK 、 SIG_KERNEL_COREDUMP_MASK 、 SIG_KERNEL_IGNORE_MASK 和 SIG_KERNEL_STOP_MASK获取
-
信号处理
与信号相关的一些系统调用:
-
发送信号系统调用
sys_tkill
//kernel/signal.c //tkill系统调用,向单个进程发送一个信号 asmlinkage long sys_tkill(int pid, int sig)
-
处理信号队列
系统调用不会触发信号队列的处理,在
每次由核心态切换到用户状态时,内核都会发起信号队列处理
.处理是在entry.S
的汇编语言代码中发起的,因此实现自然非常特定于体系结构。不考虑特定的体系结构,执行该操作最终的效果就是调用 do_signal 函数
。尽管它也是平台相关的,但在所有系统上的行为都大致相同//arch/x86/kernel/signal_32.c //每次由核心态切换到用户状态时在entry.S中由汇编代码调用处理信号 static void fastcall do_signal(struct pt_regs *regs)
- do_signal
- get_signal_to_deliver 收集了与需要传送的下一个信号有关的所有信息。它也从特定于进程的待决信号链表中删除该信号
- handle_signal 操作进程在用户状态下的栈,使得在从核心态切换到用户状态之后运行信号处理程序
- do_signal
-
管道和套接字
管道和套接字是流行的进程间通信机制。这里只概述这两个概念的工作方式,因为二者都大量使用了内核的其他子系统。管道使用了虚拟文件系统对象,而套接字使用了各种网络函数以及虚拟文件系统。
管道是用于交换数据的连接。一个进程向管道的一端供给数据,另一个在管道另一端取出数据,供进一步处理。几个进程可以通过一系列管道连接起来
管道是进程地址空间中的数据对象,在用fork或clone复制进程时同样会被复制。使用管道通信的程序就利用了这种特征。在exec系统调用用另一个程序替换子进程之后,两个不同的应用程序之间就建立了一条通信链路(必须把管道描述符重定向到标准输入和输出,或者调用dup系统调用,以确保exec调用时不会关闭文件描述符)。
套接字对象在内核中初始化时也返回一个文件描述符,因此可以像普通文件一样处理。但不同于管道,套接字可以双向使用,还可以用于与通过网络连接的远程系统通信(这并不意味着套接字无法用于支持本地系统上两个进程之间的通信)
总结
内核竞态解决机制
原子操作
:一般由CPU指令提供,保证操作的原子性,简单的加1,减1等操作自旋锁
:这些是最常用的锁选项。它们用于短期保护
某段代码,以防止其他处理器的访问。在内核等待自旋锁释放时,会重复检查是否能获取锁,而不会进入睡眠状态(忙等待
)。当然,如果等待时间较长,则效率显然不高信号量
:这些是用经典方法实现的。在等待信号量释放时,进程进入睡眠
状态,直至被唤醒。唤醒后,内核才重新尝试获取信号量。互斥量是信号量的特例,互斥量保护的临界区,每次只能有一个用户进入读者/写者锁
:这些锁会区分对数据结构的两种不同类型的访问。任意数目的处理器都可以对数据结构进行并发读访问,但只有一个处理器能进行写访问。事实上,在进行写访问时,读访问是无法进行的RCU机制
:性能好,有内存开销.原理:要修改某个结构体时,先创建一个副本进行修改,然后等对原来的结构的读操作全部完成后,将其替换为副本内存和优化屏障
:为提高代码性能,编译器或处理器会对代码重排优化,可能导致锁无法保证时序正确.内存和优化屏障可阻止处理器和编译器进行代码重排per-CPU变量
:多个CPU对同一变量互斥操作时会导致其它CPU等待,使系统性能下降.对于一些不必了解其准确数值的变量(只需知道模糊值,如大小等)使用该机制能提高性能.原理:为变量定义一个准确值,然后为每个CPU定义一个对该变量的操作变量.当有CPU想修改该值时,先将要对该变量的操作记录在对应的CPU变量中(如+3,就记录为3).任何CPU读取变量值时,由于还未对准确值进行操作,这时读到的是模糊值(近似值).当某个CPU的对该变量的修改后的绝对值超出了某个阈值后,则对该变量进行实际的互斥修改操作.由于这种改变很少发生,因此互斥操作的代价将很小
System V 3种IPC机制
3种IPC机制(信号量,消息队列,共享内存)都使用 ipc_ids 结构体表示(每种都定义了单独的实例),具体的结构(sem_array,msg_queue,shmid_kernel)存在基数树 ipc_ids->ipcs_idr 中,通过id获取存在基数树中的 kern_ipc_perm 结构,然后将其转化为对应的IPC结构体(sem_array,msg_queue,shmid_kernel)
对信号量 sem_array 来说,待决的信号存在链表 sem_array->sem_pending 中
对消息队列 msg_queue 来说,消息存在链表 msg_queue->q_messages 中,消息结构体为 msg_msg,每个消息占一页内存.同一编号的消息按 先进先出
次序处理
对共享内存 shmid_kernel 来说,创建了伪文件 shmid_kernel->shm_file 和匿名映射,并关联到使用的进程
其它IPC机制
信号,管道(依赖虚拟文件系统),套接字(依赖网络系统)
IDR机制原理:
IDR机制适用在那些需要把某个整数和特定指针关联在一起的地方。IDR是redix树的一种应用,在32位机器上它把一个32位的整形分成7级,每5位(或者2位)为一级。
31 30 | 29 28 27 26 25 | 24 23 22 21 20 | 19 18 17 16 15 | 14 13 12 11 10 | 9 8 7 6 5 | 4 3 2 1 0
这样形成的redix树,根节点有22=4个子结点,之后这四个子结点每层的结点有25=1024个结点
=========================================
涉及的命令和配置:
kill -9 pid:
sys_kill根据传递的PID形式,向几个进程发送信号。
pid > 0,则将信号发送到指定PID对应的进程。
pid = 0,则向发送信号的进程所在进程组的所有成员,发送该信号。
pid = -1,则向所有pid > 1的进程发送该信号。
pid = -pgrp < -1,则向pgrp进程组的所有成员发送该信号。
ipcs -m 表示查看系统中已经存在的共享内存
ipcrm -m 共享内存的ID 表示删除指定的共享内存
ipcs -q 表示查看系统中已经存在的消息队列
ipcrm -q 消息队列的ID 表示删除指定的消息队列
ipcs 查看所有ipc信息
mkfifo xxx.pipe 表示创建管道文件
CONFIG_RT_MUTEX 启用解决优先级反转问题的实时互斥量
CONFIG_SYSVIPC 启用system V 提供的ipc功能,即信号量,共享内存,消息队列