现代操作系统有三大特征:中断处理、多任务处理和多处理器(SMP)
。这些特性导致当多个进程、线程或者CPU同时访问一个资源时,可能会发生错误,这些错误是操作系统运行所不允许的。在操作系统中,内核需要提供并发控制机制,对公共资源进行保护。本章将对保护这些公共资源的方法进行简要的介绍。
并发
是指在操作系统中,一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一处理机上运行,但任一时刻点上只有一个程序在处理机上运行。并发容易导致竞争问题。竞争
就是两个或两个以上的进程同时访问一个资源,从而引起资源的错误。
例如,在数据库中,允许多个用户同时访问和更改共享数据的进程。允许多个用户同时访问和更改共享数据就很可能发生冲突。以飞机售票为例,会引起数据不一致的错误。
例如,飞机订票系统中的一个活动序列:
(1)甲售票员读出某航班的机票余额为A,设A=16;
(2)乙售票员读出同一航班的机票余额A,也为16;
(3)甲售票员点卖出一张机票,修改机票的余额为A=A-1,即A=15,把A写回数据库;
(4)乙售票员也卖出一张机票,修改机票余额A=A-1,即是A=15,把A写回数据库。
结果:卖出两站机票,但数据库中机票余额只减少1张。这种情况就是并发导致的问题。本章将介绍一些机制避免并发对系统资源的影响。这些并发控制机制有原子变量操作、自旋锁、信号量和完成量
。下面对这几种机制进行详细的介绍。
原子变量操作是Linux中提供的一种简单的同步机制,是一种在操作过程中不会被打断的操作,所以在内核驱动程序中非常有用。本节对Linux中原子变量的操作进行详细的分析。
所谓原子变量操作
,就是该操作绝不会在执行完毕前被任何其他任务或事件打断。也就是说,原子变量操作是一种不可打断的操作。原子操作需要硬件的支持,因此是架构相关的,其API和原子类型的定义都在内核源码树的include/asm/atomic.h
文件中,它们都使用汇编语言实现,因为C语言并不能实现这样的操作
。
原子变量操作不会只执行一半,又去执行其他代码。它要么执行完毕,要么一点也不执行。原子变量操作的优点是编写简单;缺点是功能太简单,只能做计数操作,保护东西太少,但却是其他同步手段的基石。在Linux中,原子变量的定义的如下:
typedef struct {
volatile int counter;
}atomic_t;
关键字volatile
用来暗示GCC不要对该类型数据优化,所以对这个变量counter的访问都是基于内存的,不要将其缓冲到寄存器中。存储到寄存器中,可能导致内存中的数据已经改变,而寄存器中的数据没有改变
。
在Linux中,定义了两种原理变量操作方法,一种是原子整型操作,另一种是原子位操作。下面分别对这两种原子变量操作方法进行分析。
有时候需要共享的资源可能只是一个简单的整型数值。例如在驱动程序中,需要对包含一个count的计数器。这个计数器表示有多个应用程序打开了设备所对应的设备文件。通常在设备驱动程序的open()
函数中,将count变量加1。在close()
函数中,将count减1。如果只有一个应用程序执行打开和关闭操作,那么这里的count计数就不会出现问题。但是如果有多个应用程序同时打开或者关闭设备文件,那么就可能导致count多加或者少加,出现错误。
为了避免这个问题,内核提供了一个原子整型变量,称为atomic_t
。该变量的定义如下:
typedef struct {
volatile int counter;
}aotmic_t;
一个atomic_t
变量实际上是一个int
类型的值,但是由于一些处理器的限制,该int类型的变量不能表示完整的整数范围,只能表示24位数的范围。在SPARC处理器架构上,对原子操作缺乏指令级的支持,所以只能将32位中的低8位设置成一个锁,用户保护整型数据的并发访问。
在Linux中,定义一个atomic_t
类型的变量与C语言中定义个类型的变量没有什么不同。例如,下面的的代码定义了前面说的count计数器。
atomic_t count;
这句话定义了一个atomic_t
类型的count变量,atomic_t
类型的变量只能通过Linux内核中定义的专用函数来操作,不能在变量上直接加1或者减1。下面介绍一下Linux中针对atomic_t
类型的变量的操作函数。
1.定义atomic_t变量
ATOMIC_INIT
宏的功能是定义一个atomic_t
类型的变量,宏的参数是需要该变量初始化的值。该宏定义如下:
#defien ATOMIC_INIT(i) {(i)}
因为atomic_t
类型的变量是一个结构体类型,所以对其进行定义和初始化应该用结构体定义和初始化的方法。例如定义一个名为count的atomic_t
类型的变量的方法,代码如下:
atomic_t count = ATOMIC_INIT(0);
这句代码展开后,就是一个定义和初始化一个结构体的方法,展开后的代码如下:
atomic_t count = {(0)};
2.设置atomic_t变量的值
atomic_set(v,i)
宏用来设置v变量的值为i。其定义如下代码所示。
#define atomic_set(v, i) (((v)->counter) = i)
3.读atomic_t变量的值
atomic_read(v)
宏来读v变量的值。其定义如下代码所示。
#define atomic_read(v) ((v)->counter)
该宏对原子类型的变量进行原子读操作,它返回原子类型的变量v的值。
4.原子变量的加减法
atomic_add()
函数用来将第一个参数i的值加到第二个参数v中,并返回一个void值。返回空的原因是将耗费更多的CPU时间,而大多数情况下原子变量的加法不需要返回值。atomic_add()
函数的原型如下代码所示。
static inline void atomic_add(int i, volatile atomic_t *v)
与atomic_add()
函数功能相反的函数是atomic_sub()
函数,该函数从原子变量v中减去i的值。atomic_sub()
函数的原型如下代码所示。
static inline void atomic_sub(int i, volatile atomic_t *v)
5.原子变量的自加自减
atomic_inc()
函数用来将v指向的变量加1,并返回一个void值。返回空的原因是将耗费更多的CPU时间,而大多数情况下原子变量的加法不需要返回值。atomic_inc()
函数的原型如下代码所示。
static inline void atomic_inc(volatile atomic_t *v)
与atomic_inc()
函数功能相反的函数是atomic_dec()
函数,该函数从原子变量v中减去1。atomic_dec()
函数的原型如下代码所示:
static inline void atomic_dec(volatile atomic_t *v)
6.加减测试
atomic_inc_and_test()
函数用来将v指向的变量加1,如果结果是0,则字节返回真;如果是非0,则返回假。atomic_inc_and_test()
函数原型如下代码所示。
static inline int atomic_inc_and_test(volatile atomic_t *v)
与atomic_inc_and_test()
函数功能相反的函数是atomic_dec_and_test()
函数,该函数从原子变量v中减去1。如果结果是0,则字节返回真;如果返回是非0,则返回假。 atomic_dec_and_test()
函数的原型如下代码所示。
static inline int atomic_dec_and_test(volatile atomic_t *v)
综上所述,atomic_t
类型的变量必须使用上面介绍的函数来访问,如果试图将原子变量看作整型变量来使用,则会出现编译错误。
除了原子整型操作外,还有原子位操作。原子位操作是根据数据的每一位单独进行操作。根据体系结构不同,原子位操作函数的实现也不同。这些函数的原型如下代码所示。
static inline void set_bit(int nr, volatile unsigned long *addr)
static inline void clear_bit(int nr, volatile unsigned long *addr)
static inline void change_bit(int nr, volatile unsigned long *addr)
static inline int test_and_set_bit(int nr, volatile unsigned long *addr)
static inline int test_and_clear_bit(int nr, volatile unsigned long *addr)
static inline int test_and_change_bit(int nr, volatile unsigned long *addr)
需要注意的是,原子位操作和原子整型操作是不同的。原子位操作不需要专门定义一个类似atomic_t
类型的变量,只需要一个普通的变量指针就可以了。下面对上面的几个函数进行简要的分析:
set_bit()
函数将addr变量的第nr位设置为1.clear_bit()
函数将addr变量的第nr位设置为0.change_bit()
函数将addr变量的第nr位设置为相反的数.test_and_set_bit()
函数将addr变量的第nr位设置为1,并返回没有修改之前的值.test_and_clear_bit()
函数将addr变量的第nr位设置为0,并返回没有修改之前的值.test_and_change_bit()
函数将addr变量的第nr位设置为相反的数,并返回没有修改之前的值.set_bit()
函数相对应的是__set_bit()
函数,这个函数不会保证是一个原子操作。与此类似的函数原型如下所示。static inline void __set_bit(int nr, volatile unsigned long *addr)
static inline void __clear_bit(int nr, volatile unsigned long *addr)
static inline void __change_bit(int nr, volatile unsigned long *addr)
static inline void __test_and_set_bit(int nr, volatile unsigned long *addr)
static inline void __test_and_clear_bit(int nr, volatile unsigned long *addr)
static inline void __test_and_change_bit(int nr, volatile unsigned long *addr)
自旋锁是一种简单的并发控制机制,其是实现信号量和完成量的基础。自旋锁对资源有很好的保护作用,在Linux驱动程序中进程使用,本节将对自旋锁进行详细的介绍。
在Linux中提供了一些锁机制来避免竞争条件,最简单的一种就是自旋锁。引入锁的机制,是因为单独的原子操作不能满足复杂的内核设计需要。例如,当一个临界区域要在多个函数之间来回运行时,原子操作就显得无能为力了。
Linux中一般可以认为有两种锁,一种是自旋锁,另一种是信号量。这两种锁是为了解决内核中遇到的不同问题开发的。其实现机制和应用场合有所不同,下文将分别对两种锁机制进行介绍。
在Linux中,自旋锁的类型为struct spinlock_t
。内核提供了一系列的函数对struct spinlock_t
进行操作。下面对自旋锁的操作方法进行简要的介绍。
1.定义和初始化自旋锁
在Linux中,定义自旋锁的方法和定义普通结构体的方法相同,定义方法如下代码所示。
spinlock_t lock;
一个自旋锁必须初始化才能被使用,对自旋锁的初始化可以在编译阶段通过宏来实现,初始化自旋锁可以使用宏SPIN_LOCK_UNLOCKED
,这个宏表示一个没有锁定的自旋锁,其代码形式如下代码所示。
spinlock_t lock=SPIN_LOCK_UNLOCKED; /*初始化一个未使用的自旋锁*/
在运行阶段,可以使用spin_lock_init()
函数动态地初始化一个自旋锁,这个函数的原型如下:
void spin_lock_init(spinlock_t lock)
2.锁定自旋锁
在进入临界区前,需要使用spin_lock
宏来获得自旋锁。spin_lock
宏的代码如下:
#define spin_lock(lock) _spin_lock(lock)
这个宏用来获得lock自旋锁,如果能够立即获得自旋锁,则宏立刻返回;否则,这个锁会一直自旋在哪里,直到该锁被其他线程释放为止。
3.释放自旋锁
当不再使用临界区时,需要使用spin_unlock
宏释放自旋锁。spin_unlock
宏的代码如下:
#define spin_unlock(lock) _spin_unlock(lock)
这个宏用来释放lock自旋锁,当调用该宏之后,锁立刻被释放。
4.使用自旋锁
这里给出一个自旋锁的使用方法,首先是定义自旋锁,然后初始化、获得自旋锁和释放自旋锁。其代码如下:
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
//临界资源
spin_unlock(&lock);
在驱动程序中,有些设备只允许打开一次,那么就需要一个自旋锁保护表示设备的打开或者关闭状态的变量count。此处count属于一个临界资源,如果不对count进行保护,当设备打开频繁时,可能出现错误的count计数,所以必须对count进行保护。使用自旋锁包含count的代码如下:
int count=0;
spinlock_t lock;
int xxx_init(void)
{
...
spin_lock_init(&lock);
....
}
/*文件打开函数*/
int xxx_open(struct inode *inode, struct file *filp)
{
...
spin_lock(&lock);
if(count)
{
spin_unlock(&lock);
return -EBUSY;
}
count++;
spin_unlock(&lock);
...
}
/*文件释放函数*/
int xxx_release(struct inode *inode, struct file *filp)
{
...
spin_lock(&lock);
count--;
spin_unlock(&lock);
...
}
在使用自旋锁时,有几个注意事项需要理解,这几个注意事项是:
void A()
{
//锁定自旋锁
A();
//解锁自旋锁;
}
本节介绍锁的另一种实现机制,这种机制就是Linux中常用的信号量。Linux中提供两种信号量,一种用于内核程序中,一种用于应用程序中。由于这里讲解的是内核编程的知识,所以只对内核中的信号量进行详细讲述。
和自旋锁一样,信号量也是保护临界资源的一种有用方法。信号量与自旋锁的使用方法基本一样。与自旋锁相比,信号量只有当得到信号量的进程或者线程时才能够进入临界区,执行临界代码。信号量与自旋锁的最大不同点在于,当一个进程试图去获取一个已经锁定的信号量时,进程不会像自旋锁一样在远处忙等待,在信号量中采用另一种方式,这种方式如下所述。
当获取的信号量没有释放时,进程会将自身加入一个等待队列中去睡眠,直到拥有信号量的进程释放信号量后,处于等待队列中的那个进程才被唤醒。当进程唤醒后,就立刻重新从睡眠的地方开始执行,又一次试图获得信号量,当获得信号量后,程序继续执行。
从信号量的原理上来说,没有获得信号量的函数可能睡眠。这就要求只有能够睡眠的进程才能够使用信号量,不能睡眠的进程不能使用信号量。例如在中断处理程序中,由于中断需要立刻完成,所以不能睡眠,也就说在中断处理程序中是不能使用信号量的
。
根据不同的平台,其提供的指令代码有所不同,所以信号量的实现也有所不同。在Linux中,信号量的定义如下代码所示。
struct semaphore{
spinlock_t lock;
unsigned int count;
struct list_head wait_list;
}
下面详细介绍这个结构体的成员变量:
1.lock自旋锁
lock
自旋锁的功能比较简单,用来对count变量起保护作用。当count要变化时,内部会锁定lock
锁,当修改完成后,会释放lock
锁。
2.count变量
count是信号量中一个非常重要的成员变量,这个变量可能取下面的3种值。
wait_list
队列种没有进程在等待信号量。wait_list
队列种等待信号量被释放。count
的值,设定可以有多个进程持有这个信号量。根据count
的取值,可以将信号量分为二值信号量和计数信号量。3.等待队列
wait
是一个等待队列的链表头,这个链表将所有等待该信号量的进程组成一个链表结构。在这个链表中,存放了正在睡眠的进程链表。在Linux中,信号量的类型为struct semaphore
。内核提供了一系列的函数对struct semaphore
进行操作。下面将对信号量的操作方法进行简要的介绍。
1.定义和初始化信号量
在Linux中,定义信号量的方法和定义普通结构体的方法相同,定义方法如下代码所示。
struct semaphore sema;
一个信号量必须初始化才能被使用,sema_init()
函数用来初始化信号量,并设置sema
中count的值为val。其代码形式如下所示。
static inline void sema_init(struct semaphore *sem, int val)
另一个宏可以初始化一个信号量的值为1的信号量,这种信号量又叫互斥体,其定义如下:
#define init_MUTEX(sem) sema_init(sem, 1)
该宏用于初始化一个互斥的信号量,并将这个信号量sem的值设置为1,等同于sema_init(sem ,1)
。另一个宏init_MUTEX_LOCKED
也用来初始化一个信号量,其将信号量sem的值设置为0,定义如下:
#define init_MUTEX_LOCKED(sem) sema_init(sem, 0)
2.锁定信号量
在进入临界区前,需要使用down()
函数来获取信号量。down()
函数的代码如下:
void down(struct semaphore *sem)
该函数会导致睡眠,所以不能在中断上下文使用。另一个函数与down()
函数相似,其代码如下:
int down_interruptible(struct semaphore *sem)
该函数与down()
函数非常相似,不同之处在于,down()
函数进入睡眠之后,就不能够被信号唤醒。而down_interruptible()
函数进入睡眠后可以被信号唤醒。如果被信号唤醒,那么会返回非0值。所以在调用down_interruptible()
函数时,一般应该检查返回值,判断被唤醒的原因。其代码如下:
if(down_interruptible(&sem))
{
return -ERESTARTSYS;
}
3.释放信号量
当不再使用临界区时,需要使用up()
函数释放信号量,up()
函数代码如下:
void up(struct semaphore *sem)
4.使用信号量
下面给出一个信号量的使用方法,首先是定义信号量,然后初始化,获得信号量和释放信号。其代码如下:
struct semaphore sem;
int xxx_init(void)
{
...
init_MUTEX(&lock);
....
}
/*文件打开函数*/
int xxx_open(struct inode *inode, struct file *filp)
{
...
down(&sem);
/*不允许其他进程访问这个程序的临界资源*/
...
return 0;
}
/*文件释放函数*/
int xxx_release(struct inode *inode, struct file *filp)
{
...
up(&sem);
...
}
5.信号量用于同步操作
前面已经说过,如果信号量被初始化为0,那么又可以将信号量叫做互斥体
。互斥体可以用来实现同步的功能。同步表示一个线程的执行需要依赖于另一个线程的执行,这样可以保证线程的执行先后顺序。如下图所示,线程A执行到被保护代码A之前,一直处于睡眠状态。直到线程B执行完被保护代码B并调用up()
函数后,才会执行被保护代码A。信号量的同步操作对于很多驱动程序来说是非常有用的,需要引起程序员的注意。
自旋锁和信号量是解决并发控制的两个很重要的方法。在使用时,应该如何选择它们其中的一种方法呢?这要根据被包含资源的特定来确定。
自旋锁是一种最简单的保护机制,从上面的代码分析可以看出,自旋锁的定义只有一个结构体成员。当被包含的代码能够在很短的时间内执行完成时,那么使用自旋锁是一种很好的选择。因为自旋锁只是忙等待,不会进入睡眠。要知道,睡眠是一种非常浪费时间的操作。
信号量用来在多个进程之间互斥。信号量的执行可能引起进程的睡眠,睡眠需要进程上下文的切换,这是非常浪费时间的一项工作。所以只有在一个进程对被保护资源的占用时间比进程切换的时间长很多时,信号量才是一种更好的选择,否则,会降低系统的执行效率。
在驱动程序开发中,一种常见的情况是:一个线程需要等待另一个线程执行完某个操作后,才能继续执行。前面讲的信号量其实也能够完成这种工作,但其效率比Linux中专门针对这种情况的完成量机制要差一些。本节将对完成量进行详细的介绍。
Linux中提供了一种机制,实现一个线程发送一个信号通知另一个线程开始完成某个任务,这种机制就是完成量。完成量的目的告诉一个线程某个事件已经发生,可以在此事件基础上做你想做的另一个事件了。其实完成量和信号量比较类似,但是在这种线程通信的情况下,使用完成量有更高的效率。在内核中,可以通过进程看见使用完成量的代码。完成量是一种轻量级的机制,这种机制在一个线程希望告诉另一个线程某个工作已经完成的情况下是非常有用的。
完成量是实现两个任务之间同步的简单方法,在内核种完成量由struct completion
结构体表示。该结构体定义在include\linux\completion.h
文件中,其定义如下代码如是。
struct completion{
unsigned int done;
wait_queue_head_t wait;
}
下面详细介绍这个结构体的两个成员变量。
1.done成员
done
成员用来维护一个计数。当初始化一个完成量时,done
成员被初始化为1.由done
的类型可以知道这是一个无符号类型,其值永远大于0.当done
等于0时,会将拥有完成量的线程置于等待状态;当done
的值大于0时,表示等待完成量的函数可以立刻执行,而不需要等待。
2.wait成员
wait是一个等待队列的链表头,这个链表将所有等待该完成量的进程组成一个链表结构。在这个链表中,存放了正在睡眠的进程链表。
在Linux中,信号量的类型为struct completion
。内核提供一系列的函数对struct completion
进行操作。下面对完成量的操作方法进行简要介绍。
1.定义和初始化完成量
在Linux中,定义完成量的方法和定义普通结构体的方法相同,定义方法如下:
struct completion com;
一个完成量必须初始化才能被使用,init_completion()
函数用来初始化完成量。其定义如下:
static inline void init_completion(struct completion *x)
{
x->done = 0;
init_waitqueue_head(&x->wait); /*初始化等待队列头*/
}
还可以使用宏DECLARE_COMPLETION
定义和初始化一个变量,定义如下:
#define DECLEAR_COMPLETION(work) \
struct completion work = COMPLETION_INITIALIZER(work)
#define COMPLETION_INITIALIZER(work) \
{0, __WAIT_QUEUE_HEAD_INITIALIZER((work).wait)}
仔细分析这个宏,可以发现其宏和init_completion()
函数实现的功能一样,只是定义和初始化一个完成量的简单实现而已。
2.等待完成量
当要实现同步时,可以使用wait_for_completion()
函数等待一个完成量。其函数代码如下:
void __sched wait_for_completion(struct completion *x)
该函数会执行一个不会被信号中断的等待。如果调用这个函数之后,没有一个线程完成这个完成量,那么执行wait_for_completion()
函数的线程会一直等待下去,线程将不可以退出。
3.释放完成量
当需要同步的任务完成之后,可以使用下面的两个函数唤醒完成量。当唤醒之后wait_for_completion()
函数之后的代码才可以继续执行。这两个函数的定义如下:
void complete(struct completion *x)
void completion_all(struct completion *x)
前者只唤醒一个等待的进程或者线程,后者将唤醒所有等待的进程或者线程。
4.使用完成量
下面给出一个完成量的使用方法,首先是定义完成量,然后初始化、获得完成量和释放完成量。其代码如下:
struct completion com;
int xxx_init(void)
{
...
init_completion(&com);
...
}
int xxx_A()
{
...
/*代码A*/
wait_for_completion(&com);
/*代码B*/
...
return 0;
}
int XXX_B()
{
...
/*代码C*/
complete(&com);
...
}
代码中,xxx_init()
函数完成了完成量的初始化。在xxx_A()
函数中代码会一直执行到wait_for_completion()
函数,如果此时complete->done
值为0,那么线程会进入睡眠。如果此时的值大于0,那么wait_for_completion()
函数会将complete->done
的值减1,然后执行代码B的部分。
在执行xxx_B()
函数的过程中,无论如何C代码都可以顺利执行,compete()
函数会将complete->done
的值加1,然后唤醒complete->wait
中的一个线程。如果碰巧这个线程是执行的xxx_A()
函数线程,那么会将这个线程从complete->wait()
队列中唤醒并执行。
本章介绍了Linux中内核的并发控制,分别介绍了完成并发控制功能的原子变量操作、自旋锁、信号量和完成量。这些都是内核中广泛使用的机制。每种机制都有自己的一些特点和使用范围。读者在使用时,应该对这些特定进行比较,选择出适合要求的并发控制机制。只有这样,才可以写出高效稳定的程序。