由于Linux内核支持对称多处理器(SMP),并且支持内核抢占,这样可以让多个进程并发的执行。当然这也带来了一些影响,就是当多个进程可以对相同的数据进行访问和操作时,会产生一些意料之外的结果。例如,在火车站的售票系统中,多个窗口发售相同车次的车票。当只有最后一张票时,多个窗口同时查询数据库(类似多处理器),发现有一张票,若此时同时出售,就会出现一张票被出售多次的现象。通常称这为竞争条件,而火车票的剩余数则为临界区。那内核如何避免这种现象发生,即防止竞争条件,就是解决内核同步问题。

       最简单的一种方式是,避免共享数据。这样不会有临界区,也肯定不会有竞争条件。这显然是不现实的,最常见的共享就是硬件资源。这种方式并不能完全解决同步问题,只能是我们在代码实现上避免使用全局变量,减少临界区,从而避免竞争条件。

       在Linux中,提供了一种数据和操作——原子数据和原子操作。对于原子数据的访问和操作都是原子操作,同一时间只能有一次执行,并且执行过程不可分割。将上述的售票过程作为一个原子操作,就不会产生一张车票被多次出售的情况。

<asm/atomic.h>
/*
 *原子整数操作:允许对一个整数进行原子操作
 *
 **/
 ATOMIC_INT(int i);
 int atomic_read(atomic_t *v);
 void atomic_set(atomic_t *v, int i);
 
<asm/bitops.h>
/*
 *原子位操作:允许对某一位进行原子操作
 *
 **/
 void set_bit(int nr, void *addr);
 void clear_bit(int nr, void *addr);

       另外一种方式是,对于临界区,建立一种访问和操作的规则:对临界区进行加锁,只允许拥有锁的进程进行处理,而锁的实现方式则根据临界区和其操作不同而不同。Linux中有如下的方式实现加锁机制。

       1)自旋锁,在同一时间,只允许一个可执行线程持有。当一个执行线程试图获取一个已经被持有的自旋锁时,该执行线程会一直进行忙循环旋转——等待锁重新可用。即获取自旋锁时,不会导致线程睡眠。

<linux/spinlock.h>

DEFINE_SPINLOCK(mr_lock);

spin_lock(&mr_lock);

spin_unlock(&mr_lock);

       2)信号量,是由Dijkstra提出的,通过P、V原子操作(Proberen和Vershogen,荷兰语,测试操作和增加操作),来实现信号量机制。如果一个线程试图获取一个已经被完全占用的信号量时,信号量操作会使其进入一个等待队列睡眠。当有可用的信号量被释放时,信号量操作会将其唤醒。信号量是有一个使用者数量(usage count),即同时允许几个线程同时访问信号量(对信号量的P、V操作属于原子操作)。

<asm/semaphore.h>

struct semaphore {
raw_spinlock_t		lock;
unsigned int		count;
struct list_head	wait_list;
};

sema_init(struct semaphore *, int);

/*
 * p v操作由Dijstra提出,后来系统中将其定义为down()和up()
 *
 * 实现的伪代码如下:
 *     down():
 *         若信号量可用,则值减1;
 *         若信号量不可用,则将线程加入到该信号量的等待队列中。
 * 
 *     up():
 *         若等待队列为空,则值加1;
 *         若等待队列不为空,则唤醒该信号量等待队列中的一个线程。
 *
 **/    

int down(struct semaphore *sem)
{
    int res = 0
	if (sem->count > 0) {
		sem->count--;
	} else { 
	    res = add_wait_list(sem);     
	}
	return res;
}

void up(struct semaphore *sem)
{
	if (list_empty(&sem->wait_list))
		sem->count++;
	else
		rm_wait_list(sem);	
}

       3)互斥体,当信号量的使用者数量为1时,为互斥体。互斥体是一种特殊的信号量,在同一时间只允许一个线程持有该信号量。

DEFINE_MUTEX(mutex);
mutex_init(&mutex);
mutex_lock(&mutex);
mutex_unlock(&mutex);

       4)完成变量,如果内核中一个任务需要发出信号通知另一任务发生了某个特定事件,利用完成变量是使两个任务得以同步的简单方法。当子进程执行或退出式,vfork()就是使用完成变量唤醒父进程。

<linux/completion.h>

struct completion {
	unsigned int done;
	wait_queue_head_t wait;
};
 
init_completion(struct completion *);
wait_for_completion(struct completion *);
complete(struct completion *);

       5)顺序锁,主要用于读写共享,同一时间只能运行一个线程进行写操作,但可以运行多个线程读操作。当写线程获取顺序锁时,序列号加1(序列号初始化为0),写操作完成释放时序列号再加1。当有读线程获取顺序锁时先获取序列号,读操作完成时,再次获取序列号,若两个值相同,则表示进行读取过程未写操作。在内核中,对于jiffies_64变量的操作就是使用顺序锁完成的。

/*
 * 在32位系统时,对变量jiffies_64不能原子访问其两个32位变量。
 *
 **/   
write_seqlock(&xtime_lock);
jiffies_64 += 1;
write_sequlock(&xtime_lock);

       另外可以通过禁止内核抢占,来避免竞争条件发生。在某些情况下,并不需要自旋锁,当仍需要关闭内核抢占。例如:每个处理器的数据,由于只能被该处理器访问,因此只需要禁止内核抢占就可以避免竞争条件发生。

preempt_disable();
/*此处执行的代码,禁止抢占*/
preempt_enable();

       在使用锁机制时要注意:多个线程竞争一个或多个资源时,可能会产生死锁的状态——每个线程都在等待资源,但资源都已经被线程相互占用,所有线程都在等待其它线程释放资源,从而导致死锁的状态。常用的避免死锁的方式:按顺序加锁;不要重复请求同一个锁;设计力求简单,不要使用复杂的加锁方案,避免死锁。