前面讲过,共享内存为进程间通信提供了一种效率很高的手段,但是这种机制所提供的只是狭义的通信手段,而并不提供进程间同步的机能。所以,共享内存作为广义的进程间通信手段还必须要有其它机制的配合。同时,除共享内存以外,还有其他需要共享资源的场合也需要有进程间同步的手段。举例来说,如果有两个进程共享同一个tty终端。并且各自都通过printf在屏幕上显示一些字符串,就很可能使来自两个不同进程的字符在屏幕上混成一片,而令人无法阅读。所以,原则上讲只要两个进程直接共享某个资源,就得要有互相同步的手段。其中有些同步是由内核自动提供的(例如对CPU,对管道机制中的缓冲区,等等),而有些则要由所涉及的进程自己来关心。
由此可见,将已经在内核中使用的进程间同步机制信号量推广到用户空间,是很自然的事。sysv ipc中的信号量机制就是这样一种推广,所以实际上应该称为用户空间信号量以示区别。不过,只要不至于引起混淆,我们在文中就还是称之为信号量,读者应该注意区分用户空间信号量与以前讲过的内核信号量这二者之间的区别。同时,还要认识到用户空间信号量这种机制是由内核来支持,在系统空间中实现的,只不过是由用户进程直接使用而已。
与信号量有关的操作由三种,就是SEMGT、SEMOP、SEMCTL,分别由sys_semget、sys_semop和sys_semctl实现。有关的代码和定义基本都在文件ipc/sem.c和include/linux/sem.h中。
库函数semget--创建或寻找信号量
函数sys_semget的代码与sys_msgget几乎一模一样,主要的区别只是把sys_msgget中的子程序调用newque换成了newary。所以我们就来看看newary(ipc/sem.c):
static int newary (key_t key, int nsems, int semflg)
{
int id;
struct sem_array *sma;
int size;
if (!nsems)
return -EINVAL;
if (used_sems + nsems > sc_semmns)
return -ENOSPC;
size = sizeof (*sma) + nsems * sizeof (struct sem);
sma = (struct sem_array *) ipc_alloc(size);
if (!sma) {
return -ENOMEM;
}
memset (sma, 0, size);
id = ipc_addid(&sem_ids, &sma->sem_perm, sc_semmni);
if(id == -1) {
ipc_free(sma, size);
return -ENOSPC;
}
used_sems += nsems;
sma->sem_perm.mode = (semflg & S_IRWXUGO);
sma->sem_perm.key = key;
sma->sem_base = (struct sem *) &sma[1];
/* sma->sem_pending = NULL; */
sma->sem_pending_last = &sma->sem_pending;
/* sma->undo = NULL; */
sma->sem_nsems = nsems;
sma->sem_ctime = CURRENT_TIME;
sem_unlock(id);
return sem_buildid(id, sma->sem_perm.seq);
}
先看调用参数。只要回顾一下sys_msgget,这里面的参数key和semflg就很自然、很容易理解了。特殊之处在于第二个参数nsems,它表示由同一个信号量标识号所代表的数据结构中要设置几个信号量。也就是说,一个信号量标识代表着一组而不只是一个信号量,由sys_semget建立或找到的也是一组而不只是一个信号量。看一下数据结构sem_array的定义,这一点就更清楚了(include/linux/sem.h)。
/* One sem_array data structure for each set of semaphores in the system. */
struct sem_array {
struct kern_ipc_perm sem_perm; /* permissions .. see ipc.h */
time_t sem_otime; /* last semop time */
time_t sem_ctime; /* last change time */
struct sem *sem_base; /* ptr to first semaphore in array */
struct sem_queue *sem_pending; /* pending operations to be processed */
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 */
};
显然,这一次ipc_perm数据结构的宿主变成sem_array了。同样地,整个信号量机制的总根是ipc_ids数据结构sem_ids:
static struct ipc_ids sem_ids;
其中的指针entries同样指向一个ipc_id结构数组,而ipc_id结构中的指针p也同样指向一个ipc_perm数据结构,只不过它的宿主变成了sem_array数据结构。在这一点上,三种SysV IPC机制的基本格局都是一样的。可是,我们在这里注意的不是这些,而是sem_array结构中的指针sem_base,它指向一个结构数组。该数组中的每一个sem数据结构都是一个信号量:
/* One semaphore structure for each semaphore in the system. */
struct sem {
int semval; /* current value */
int sempid; /* pid of last operation */
};
这个数组的大小由参数nsems决定,其空间连同sem_array数据结构一起进行分配(123-124行)所以紧贴在sem_array结构后面,而&sma[1]就是其起始地址(139行)。
那么,为什么sys_semget要允许建立一组而不只是一个信号量呢?我们在讲述内核信号量时曾经谈到临界区嵌套很容易引起死锁的。当一项操作涉及多项共享资源时,如果先取得了其中一项,然后试图取得另一项资源不成而等待时,就可以会因为直接或间接的循环等待而形成死锁。在操作系统理论中,最典型的死锁就是由这种各自占有部分资源不放而等待其它过程释放另一部分资源而形成的所谓哲学家与刀叉问题。吃西餐既要用刀又要用叉,如果一个人拿到了刀不放而等待别人放弃他手上的叉,而另一个则拿到了叉不放而等待别人放弃他的刀,那就思索了。解决的方法是:凡是要使用多项资源就一定一步(不可分割的一步)就取得所有的资源,或者在一旦得不到某项资源时就释放手中的所有的相关资源。换句话说,对这些临界资源的取得要么是全有,要么是全无。可是,这一点对于用户空间的程序来说是难以保证的,所以要以系统调用的系统来提供这样一种机制,这就是sys_semget允许建立一组而不只是一个信号量的原因。这样一来,在建立了一组信号量以后,用户进程就可以通过SEMOP操作在一次系统调用中(因而是不可分割的)取得多项共享资源的使用权,或者就不占用任何共享资源地等待,从而防止死锁的发生。
明白了这一点,再对照sys_msgget的有关代码,这里newary的代码就很好理解了。
库函数semop--信号量操作
由于一次SEMOP操作可以是对一个信号量集合(而不仅仅是一个信号量)的操作,并且必须符合要么全有,要么全无的原则,sys_semop的代码自然就比较复杂一些了。我们分段来看(ipc/sem.c):
asmlinkage long sys_semop (int semid, struct sembuf *tsops, unsigned nsops)
{
int error = -EINVAL;
struct sem_array *sma;
struct sembuf fast_sops[SEMOPM_FAST];
struct sembuf* sops = fast_sops, *sop;
struct sem_undo *un;
int undos = 0, decrease = 0, alter = 0;
struct sem_queue queue;
if (nsops < 1 || semid < 0)
return -EINVAL;
if (nsops > sc_semopm)
return -E2BIG;
if(nsops > SEMOPM_FAST) {
sops = kmalloc(sizeof(*sops)*nsops,GFP_KERNEL);
if(sops==NULL)
return -ENOMEM;
}
if (copy_from_user (sops, tsops, nsops * sizeof(*tsops))) {
error=-EFAULT;
goto out_free;
}
这里的参数tsops是一个指针,指向用户空间中的一个sembuf结构数组,而nsops则是该数组的大小。数组中的每一项都规定了对一个信号量的操作,而对数组中所有规定的操作是原子的,也就是全有或全无。数据结构类型sembuf的定义为(include/linux/sem.h):
/* semop system calls takes an array of these. */
struct sembuf {
unsigned short sem_num; /* semaphore index in array */
short sem_op; /* semaphore operation */
short sem_flg; /* operation flags */
};
这里的sem_num为具体信号在通过SEMGET建立的一组信号量中的下标。而sem_op则为一个小小的整数,原则上这个整数为被相加到相应信号量的当前值上。如果回到我们在讨论内核信号量时所打的比喻,则当这个整数为+1时表示退还,或多供应一张门票;而-1则表示要取得一张门票;但是,也允许更大或更小的数值。从原理上说,这个数值的大小反映了要取得或供应同一种资源的数量。想家以后,如果具体信号量的数值变成了负数表示不能满足要求,此时当前进程一般就会进入睡眠等待,除非要有安排(见下文)。如果sem_op的数值为0,则信号量的当前值当然不会改变,而只是表示询问相应信号量的数值是否为0,若不为0就等待其变成0,除非另有安排。原则上,一个进程通过SEMOP操作取得资源应该由其自己通过另一次SEMOP操作归还,所以如果第一次操作中对某个信号量的sem_op为-1,而第二次就应该是+1.此外,通过sembuf结构中的sem_flg可以设置两个标志位:一个是IPC_NOWAIT,表示在条件不能满足时不要睡眠等待,而立即返回(出错代码为-EAGAIN);另一个是SEM_UNDO,表示留下遗嘱,万一当前进程欠债不还,尚未推出占有的资源就寿终正寝(exit)的话,就由内核代为退还。
回到sys_shmop的代码中,从用户空间把参数复制到内核后,继续往下看(ipc/sem.c):
sma = sem_lock(semid);
error=-EINVAL;
if(sma==NULL)
goto out_free;
error = -EIDRM;
if (sem_checkid(sma,semid))
goto out_unlock_free;
error = -EFBIG;
for (sop = sops; sop < sops + nsops; sop++) {
if (sop->sem_num >= sma->sem_nsems)
goto out_unlock_free;
if (sop->sem_flg & SEM_UNDO)
undos++;
if (sop->sem_op < 0)
decrease = 1;
if (sop->sem_op > 0)
alter = 1;
}
alter |= decrease;
error = -EACCES;
if (ipcperms(&sma->sem_perm, alter ? S_IWUGO : S_IRUGO))
goto out_unlock_free;
if (undos) {
/* Make sure we have an undo structure
* for this process and this semaphore set.
*/
un=current->semundo;
while(un != NULL) {
if(un->semid==semid)
break;
if(un->semid==-1)
un=freeundos(sma,un);
else
un=un->proc_next;
}
if (!un) {
error = alloc_undo(sma,&un,semid,alter);
if(error)
goto out_free;
}
} else
un = NULL;
这里的sem_lock和sem_checkid与保温队列机制中的msg_lock和msg_checkid相似,读者已经熟悉了。接下来就是先对用户规定的所有信号量操作进行一番统计,看看有几项是要SEM_UNDO的,有几项是要改变相应信号量的当前值的。信号量也受到类似于磁盘文件一样的访问权限保护,要改变信号量的当前值就必须要具备对它的写访问权,由函数ipcperms加以检查。如果用户在调用sys_semop时至少为一个信号量操作规定了SEM_UNDO,那就要分配一个sem_undo数据结构用来记录当前进程对每一组信号量的债务。显然,每个进程都可以有这样的债务,并且每个进程可以对多个信号量集合欠有这样的债务(要非常小心,因为这可能引起死锁),但是同一个进程对同一个信号集合的债务则只要用一个数据结构就可以描述了。所以,在进程的task_struct中维持了一条sem_undo结构队列,这就是task_struct结构中有个semundo指针的原因。
数据结构类型sem_undo的定义为(include/linux/sem.h):
/* Each task has a list of undo requests. They are executed automatically
* when the process exits.
*/
struct sem_undo {
struct sem_undo * proc_next; /* next entry on this process */
struct sem_undo * id_next; /* next entry on this semaphore set */
int semid; /* semaphore set identifier */
short * semadj; /* array of adjustments, one per semaphore */
};
每一个进程要记住欠谁的债的同时,每个信号量集合也要记住都有谁欠了它的债。所以在每个信号量集合的sem_array中也有个指针undo,用来维护一个sem_undo结构队列。而每一个sem_undo结构则有两个指针proc_next和id_next,分别用来链入到task_struct结构中的队列和sem_array结构中的队列(见下图)。函数alloc_undo分配一个sem_undo数据结构并完成两个队列的链入。
至此,所有的准备工作都已完成,下面就是实质性的操作了(ipc/sem.c):
error = try_atomic_semop (sma, sops, nsops, un, current->pid, 0);
if (error <= 0)
goto update;
函数try_atomic_semop,正如其函数名所说那样,试图将对给定的所有信号量的操作作为一个整体来完成(ipc/sem.c)。
sys_shmop=>try_atomic_semop
/*
* Determine whether a sequence of semaphore operations would succeed
* all at once. Return 0 if yes, 1 if need to sleep, else return error code.
*/
static int try_atomic_semop (struct sem_array * sma, struct sembuf * sops,
int nsops, struct sem_undo *un, int pid,
int do_undo)
{
int result, sem_op;
struct sembuf *sop;
struct sem * curr;
for (sop = sops; sop < sops + nsops; sop++) {
curr = sma->sem_base + sop->sem_num;
sem_op = sop->sem_op;
if (!sem_op && curr->semval)
goto would_block;
curr->sempid = (curr->sempid << 16) | pid;
curr->semval += sem_op;
if (sop->sem_flg & SEM_UNDO)
un->semadj[sop->sem_num] -= sem_op;
if (curr->semval < 0)
goto would_block;
if (curr->semval > SEMVMX)
goto out_of_range;
}
if (do_undo)
{
sop--;
result = 0;
goto undo;
}
sma->sem_otime = CURRENT_TIME;
return 0;
out_of_range:
result = -ERANGE;
goto undo;
would_block:
if (sop->sem_flg & IPC_NOWAIT)
result = -EAGAIN;
else
result = 1;
undo:
while (sop >= sops) {
curr = sma->sem_base + sop->sem_num;
curr->semval -= sop->sem_op;
curr->sempid >>= 16;
if (sop->sem_flg & SEM_UNDO)
un->semadj[sop->sem_num] += sop->sem_op;
sop--;
}
return result;
}
首先要注意到并记住,这里的参数do_undo为0,这是sys_semop中的第893行设置好了的。所以,如果for循环正常结束,也就是对每个信号量的操作都没有使它的值semval变成负数的话,那就已经成功地取得了需要的全部资源,此时函数返回0。除此之外,有三种情况可以使这个for循环中途夭折。
第一种情况是对某个信号量的操作使其数值超过了最大值SEMVMX。在这种情况下,本次系统调用实际上不能继续下去了,所以就转到out_of_range处把出错代码设置成-ERANGE,然后再转到undo处通过一个while循环把前面已经完成了的操作都抵消掉,让已经在for循环中改变了的信号量数值都还原。不光是信号量semval的值要还原,表示是谁最后一次改变信号量数值的semid也要还原。还有,如果SEM_UNDO标志为1的话,还要把sem_undo结构中记下的帐也还原。总之一句话,是不留痕迹。
第二种情况是对某个信号量的操作使它的值变成了负数。这表示获取由这个信号量所代表的资源(的使用权)的努力收到了阻碍,一时还得不到这种资源。一般来说,这时候就要睡眠等待了,所以同样要通过undo处的while循环将已经取得的资源全都退还,或者说将已经执行的操作都还原,也要不留痕迹。因为这里的原则是要么全有,要么全无。
第三种情况是对某个信号量的操作sem_op的值为0,而这个信号量的当前值又是非0,对这种情况的处理与第二种情况相同,也是转到would_block处。
最后,如果参数do_undo为非0呢?那表示只需要试一下,看看能否取得所有需要的资源,而并不是真的改变这些信号量的数值,所以在成功以后,即for循环正常结束以后,就将所有的操作全部还原。
从try_atomic_semop返回到sys_semop中时,返回值有三种可能。返回值为0表示所有的操作都成功了,当前进程已经握有所需的全部资源,可以返回到用户空间进入临界区继续执行了。返回值为负数表示操作失败了,并且出了错。在这两种情况下都转到标号update处,在那里进行一些善后操作,然后就反悔了(见后面的代码)。第三种情况是返回值为1,表示对某个信号量的操作失败哦了,需要睡眠等待。继续往下看(ipc/sem.c):
/* We need to sleep on this operation, so we put the current
* task into the pending queue and go to sleep.
*/
queue.sma = sma;
queue.sops = sops;
queue.nsops = nsops;
queue.undo = un;
queue.pid = current->pid;
queue.alter = decrease;
queue.id = semid;
if (alter)
append_to_queue(sma ,&queue);
else
prepend_to_queue(sma ,&queue);
current->semsleeping = &queue;
for (;;) {
struct sem_array* tmp;
queue.status = -EINTR;
queue.sleeper = current;
current->state = TASK_INTERRUPTIBLE;
sem_unlock(semid);
schedule();
tmp = sem_lock(semid);
if(tmp==NULL) {
if(queue.status != -EIDRM)
BUG();
current->semsleeping = NULL;
error = -EIDRM;
goto out_free;
}
/*
* If queue.status == 1 we where woken up and
* have to retry else we simply return.
* If an interrupt occurred we have to clean up the
* queue
*
*/
if (queue.status == 1)
{
error = try_atomic_semop (sma, sops, nsops, un,
current->pid,0);
if (error <= 0)
break;
} else {
error = queue.status;
if (queue.prev) /* got Interrupt */
break;
/* Everything done by update_queue */
current->semsleeping = NULL;
goto out_unlock_free;
}
}
current->semsleeping = NULL;
remove_from_queue(sma,&queue);
update:
if (alter)
update_queue (sma);
out_unlock_free:
sem_unlock(semid);
out_free:
if(sops != fast_sops)
kfree(sops);
return error;
}
与报文队列相似,睡眠时要将一个代表着当前进程的sem_queue数据结构链入相应sem_array数据结构的sem_pending队列。这里sem_queue数据结构的定义为(include/linux/sem.h):
/* One queue for each sleeping process in the system. */
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 */
struct sem_undo * undo; /* undo structure */
int pid; /* process id of requesting process */
int status; /* completion status of operation */
struct sem_array * sma; /* semaphore array for operations */
int id; /* internal sem id */
struct sembuf * sops; /* array of pending operations */
int nsops; /* number of operations */
int alter; /* operation will alter semaphore */
};
在将这个数据结构链入sem_pending队列时,还要区分本次操作是否要改变任何信号量的值,从而确定将其链入到队列的尾部或前部。处于队列前部的结构(实际上是进程)在被唤醒时享受到一些优先。此外,在进程task_struct结构中也有一个指针semsleeping,当进程因信号量操作而进入睡眠时就指向其sem_queue数据结构。
进入睡眠以后,就要到被唤醒时才会从921行的schedule调用中返回。那么,由谁来唤醒呢?让我们回过头去看看前面当try_atomic_semop的返回值为0或负数时转到update处以后的情况。如果本次操作改变了某些信号量的值(见956行),那就说明这个信号量集合的状态发生了某些变化,原来因条件不满足而只好睡眠等待的进程也许现在可以得到满足了,所以就调用update_queue来试试看(ipc/sem.c):
sys_semop=>update_queue
/* Go through the pending queue for the indicated semaphore
* looking for tasks that can be completed.
*/
static void update_queue (struct sem_array * sma)
{
int error;
struct sem_queue * q;
for (q = sma->sem_pending; q; q = q->next) {
if (q->status == 1)
continue; /* this one was woken up before */
error = try_atomic_semop(sma, q->sops, q->nsops,
q->undo, q->pid, q->alter);
/* Does q->sleeper still need to sleep? */
if (error <= 0) {
/* Found one, wake it up */
wake_up_process(q->sleeper);
if (error == 0 && q->alter) {
/* if q-> alter let it self try */
q->status = 1;
return;
}
q->status = error;
remove_from_queue(sma,q);
}
}
}
把311行的if (q->status == 1)暂时搁一下,先看for循环的主体。这个循环顺着队列依次让每个正在睡眠中等待的进程试一下,看看现在能否顺利完成其信号量操作。注意,这里对try_atomic_semop的最后一个调用参数为q->alter,表示如果原先的操作要改变某些信号量的值,那么现在只是试一下,而不是真的执行这些操作。试了以后的结果无非是三种:第一种是条件仍不满足,继续留在队列睡眠等待。第二种是条件仍不满足但是发生了出错,此时应将该进程唤醒,将q->status设置成由try_atomic_semop返回的出错代码,并将此数据结构从队列中摘除。既然出了错,操作已不能进行,留在队列中睡眠等待当然是毫无意义而且有害。第三种情况是某个进程的信号量操作原来因条件不满足而只好睡眠等待,但是现在条件已经能满足了,此时将该进程唤醒,并将其q->status设成1,而且for循环就此结束。也就是说,如果队列中实际上有多个进程的条件都可以得到满足,只有排在最前面的那个进程才被唤醒。唤醒时进程的sem_queue数据结构仍留在队列中。另一方面,如果对同一个信号量集合再调用一次update_queue,只要已被唤醒并且q->status已被置成1的级才能拿还在队列中尚未离开,就会在311行的if语句中将其跳出。由此可见,除了新情况下发生出错的进程不算,每次调用update_queue最多只唤醒一个进程,而且是排在前面的进程优先。因此,不要求改变信号量数值的进程得到优先的,因为它们排在队列前面。除此之外,那就是先来者优先了。
应该支持,进程本身的优先级在这里并不起任何作用。也就是说,当有两个进程都要取得对同一组资源的使用权时,优先级别较低的进程有可能先到一步而在队列中排在较前的位置上,从而就先取得了这组资源,而优先级别较高的进程就只好不搞特殊化,耐心等待了。从这个意义上,严格地将,linux并不是为实时系统而设计的。当然,要改变这一点也不难,这也是为什么已经有一些实时linux版本的原因之一。
在睡眠中等待信号量操作的进程除了可以被调用update_queue的进程唤醒之外,还可能因为接受到信号而被唤醒。由于进入睡眠前(见916行)已经把q->status设置成-EINTR,所以当因接收到信号而被唤醒时这个值仍然是-EINTR。还有一种情况,那就是如果一个信号量集合被取消了,此时所有正在睡眠等待的进程都会被唤醒,并且q->status被设置成-EIDRM。
回到sys_semop的代码中。从schedule返回以后,首先要通过sem_lock再次确认操作的对象仍是原先的信号量集合,并将其锁住。这个函数返回指向信号量集合的sem_array数据结构的指针,只有当原先的信号量集合不再存在时才返回NULL。根据唤醒后queue.status的数值可以判知被唤醒的原因。这个数值为1,表示update_queue发现该进程的条件满足了,但是情况也可能又有了变化(例如另一个进程已经在此之前对该信号量集合成功地进行了某些操作),所以940行处的try_atomic_semop还是肯恩个失败,如果失败就要回到for循环(见914行)的开始处再次入睡了。注意这里调用try_atomic_semop时的最后一个参数又是0,表示这是动真格的。要是成功了,或者出了错,那就跳出了for循环,将sem_queue结构队列中脱链以后就经过update返回了。
如果queue.status不为1呢?此时有两种可能。第一种可能是因为就接收到信号而被唤醒,所以sem_queue结构在队列中,queue.prev指针必定为非0,。此时也要跳出for循环(947行),也要将sem_queue结构从队列中脱链,并经由update返回。所不同的是,此时sys_semop的返回值必定会是-EINTR(见916行和963行)。第二种可能是因为在新的情况下发生了出错而被update_queue唤醒,并已从队列中脱链。此时也从for循环中跳出(950行),但是直接就跳到out_lock_free处返回了,并且返回值就是由update_queue所设置的出错代码。
最后,还有个事要交代一下。我们在前面讲过,调用sys_semop时将SEM_UNDO标志设成1就表示立下遗嘱,万一进程在归还所获取的资源之前就exit的话,就委托内核代为归还。实际上,既然占有资源的进程exit,则这些资源事实上已经不再被占用了。问题时相应信号量的数值没有得到调整,就好像仓库里明明有东西但账本上却说没有了。而委托内核做的事,就是平时每次都通过sem_undo树结构记下账,然后当进程exit时,根据该进程的sem_undo数据结构队列来调整有关信号量的数值。这是怎样实现的呢?读者可以回到do_exit的代码中看一下,在那里要调用一个函数sem_exit。这个函数所作的事情是:如果exit的进程中某个信号量在集合的队列中等待,就将其脱链。然后扫描该进程的semundo队列,根据每个sem_undo数据结构中的记载,依次对相应信号量集合中的相应信号量数值作出调整。最后调用update_queue唤醒可能正在等待的进程。函数的代码也在sem.c中,我们把它留给读者自己阅读。。
库函数semctl--信号量的控制与管理
与msgctl大同小异。函数sys_semctl的代码虽然不短,逻辑却很简单。我们把它留给读者了。