Linux/AndroidOS中进程间的通信线程间的同步 - 信号量
1 概述
本文将介绍 POSIX 信号量,它允许进程和线程同步对共享资源的访问。有两种类型的 POSIX 信号量:
- 命名信号量:这种信号量拥有一个名字。通过使用相同的名字调用 sem_open(),不相关的进程能够访问同一个信号量。
- 未命名信号量:这种信号量没有名字,相反,它位于内存中一个预先商定的位置处。未命名信号量可以在进程之间或一组线程之间共享。当在进程之间共享时,信号量必须位于一个共享内存区域中。当在线程之间共享时,信号量可以位于被这些线程共享的一块内存区域中(如在堆上或在一个全局变量中)。
POSIX 信号量是一个整数,其值是不能小于 0 的。如果一个进程试图将一个信号量的值减小到小于 0,那么取决于所使用的函数,调用会阻塞或返回一个表明当前无法执行相应操作的错误。
一些系统并没有完整地实现 POSIX 信号量,一个典型的约束是只支持未命名线程共享的信号量。只有在 Linux 2.6 以及带 NPTL 的 glibc 上,完整的POSIX 信号量实现才可用。
2 命名信号量
要使用命名信号量必须要使用下列函数。
- sem_open()函数打开或创建一个信号量并返回一个句柄以供后续调用使用,如果这个调用会创建信号量的话还会对所创建的信号量进行初始化。
- sem_post(sem)和 sem_wait(sem)函数分别递增和递减一个信号量值。
- sem_getvalue()函数获取一个信号量的当前值。
- sem_close()函数删除调用进程与它之前打开的一个信号量之间的关联关系。
- sem_unlink()函数删除一个信号量名字并将其标记为在所有进程关闭该信号量时删除该信号量。
POSIX 并没有规定如何实现命名信号量。一些 UNIX 实现将它们创建成位于标准文件系统上一个特殊位置处的文件。在 Linux 上,命名信号量被创建成小型 POSIX 共享内存对象,其名字的形式为 sem.name,这些对象将被放在一个挂载在/dev/shm 目录之下的专用 tmpfs 文件系统中。这个文件系统具备内核持久性——它所包含的信号量对象将会持久,即使当前没有进程打开它们,但如果系统被关闭的话,这些对象就会丢失。
在 Linux 上从内核 2.6 起开始支持命名信号量。
2.1 打开一个命名信号量
sem_open()函数创建和打开一个新的命名信号量或打开一个既有信号量。
#include <fcntl.h> /* Defines 0*constants */
#include<sys/stat.h> /* Defines mode constants */
#include <semaphore.h>/*Returns pointer to semaphore on success, Or SEM_FAILED on error*/
sem_t *sem_open(const char *name, int oflag, .../*mode t mode, unsigned int value */);
name 参数标识出了信号量。
oflag 参数是一个位掩码,它确定了是打开一个既有信号量 / 创建并打开一个新信号量。如果 oflag 为 0,那么将访问一个既有信号量。如果在 oflag 中指定了 O_CREAT,并且与给定的 name对应的信号量的不存在,那么就创建一个新信号量。如果在 oflag 中同时指定了 O_CREAT 和O_EXCL,并且与给定的 name 对应的信号量已经存在,那么 sem_open()就会失败。
如果 sem_open()被用来打开一个既有信号量,那么调用就只需要两个参数。如果在 flags中指定了 O_CREAT,那么就还需要另外两个参数:mode 和 value。(如果与 name 对应的信号量已经存在,那么这两个参数会被忽略。)具体如下。
- mode 参数是一个位掩码,它指定了施加于新信号量之上的权限。这个参数能取的位值与文件上的位值是一样的并且与 open()一样,mode 参数中的值会根据进程的 umask 来取掩码。Linux中,在打开一个信号量时会将访问模式默认成 O_RDWR,因为大多数使用信号量的应用程序都同时会用到sem_post()和 sem_wait(),从而需要读取和修改一个信号量的值。这意味着需要确保将读权限和写权限赋给每一类需要访问这个信号量的用户——owner、group 以及 other。
- value 参数是一个无符号整数,它指定了新信号量的初始值。信号量的创建和初始化操作是原子的。
不管是创建一个新信号量还是打开一个既有信号量,sem_open()都会返回一个指向一个sem_t 值的指针,而在后续的调用中则可以通过这个指针来操作这个信号量。sem_open()在发生错误时会返回 SEM_FAILED 值。(在Linux上,SEM_FAILED 被定义成了((sem_t *) 0) )。
POSIX 声称当在 sem_open()的返回值指向的 sem_t 变量的副本上执行操作(sem_post()、sem_wait()等)时结果是未定义的。换句话说,像下面这种使用 sem2 的做法是不允许的。
sem_t *sp,sem2
sp = sem_open(...);
sem2 =*sp;
sem wait(&sem2);
通过 fork()创建的子进程会继承其父进程打开的所有命名信号量的引用。在 fork()之后,父进程和子进程就能够使用这些信号量来同步它们的动作了。
示例程序
程序为 sem_open()函数提供了一个命令行界面。在 usageError()函数中给出了这个程序的命令格式。
下面的 shell 会话日志演示了如何使用这个程序。首先使用 umask 命令来否决 other 用户的所有权限,然后互斥地创建一个信号量并查看包含该命名信号量的 Linux 特有的虚拟目录中的内容。
$ umask 007
$ ./psem_create -cx /demo 666
$ ls -l /dev/shm/sem.demo
-rw-rw---- 1 dockdroid dockdroid 32 May 6 17:23 /dev/shm/sem.demo
ls 命令的输出表明进程的 umask 覆盖了为 other 用户指定的 read+write 权限。
如果再次使用同样的名字来互斥地创建一个信号量,那么这个操作就会失败,因为这个名字已经存在了。
$ ./psem_create -cx /demo 666
ERROR [EEXIST File exists] sem_open
/*
psem/psem_create.cCreate a POSIX named semaphore.Usage as shown in usageError().On Linux, named semaphores are supported with kernel 2.6 or later, anda glibc that provides the NPTL threading implementation.
*/#include <semaphore.h>
#include <sys/stat.h>
#include <fcntl.h>
#include "tlpi_hdr.h"static void
usageError(const char *progName)
{fprintf(stderr, "Usage: %s [-cx] name [octal-perms [value]]\n", progName);fprintf(stderr, " -c Create semaphore (O_CREAT)\n");fprintf(stderr, " -x Create exclusively (O_EXCL)\n");exit(EXIT_FAILURE);
}int
main(int argc, char *argv[])
{int flags, opt;mode_t perms;unsigned int value;sem_t *sem;flags = 0;while ((opt = getopt(argc, argv, "cx")) != -1) {switch (opt) {case 'c': flags |= O_CREAT; break;case 'x': flags |= O_EXCL; break;default: usageError(argv[0]);}}if (optind >= argc)usageError(argv[0]);/* Default permissions are rw-------; default semaphore initializationvalue is 0 */perms = (argc <= optind + 1) ? (S_IRUSR | S_IWUSR) :getInt(argv[optind + 1], GN_BASE_8, "octal-perms");value = (argc <= optind + 2) ? 0 : getInt(argv[optind + 2], 0, "value");sem = sem_open(argv[optind], flags, perms, value);if (sem == SEM_FAILED)errExit("sem_open");exit(EXIT_SUCCESS);
}
2.2 关闭一个信号量
当一个进程打开一个命名信号量时,系统会记录进程与信号量之间的关联关系。sem_close()函数会终止这种关联关系(即关闭信号量),释放系统为该进程关联到该信号量之上的所有资源,并递减引用该信号量的进程数。
#include <semaphore.h>
int sem_close(sem_t *sem);
打开的命名信号量在进程终止或进程执行了一个 exec()时会自动被关闭。
关闭一个信号量并不会删除这个信号量,而要删除信号量则需要使用 sem_unlink()。
2.3 删除一个命名信号量
sem_unlink()函数删除通过 name 标识的信号量并将信号量标记成一旦所有进程都使用完这个信号量时就销毁该信号量(这可能立即发生,前提是所有打开过该信号量的进程都已经关闭了这个信号量)。
#include <semaphore.h>
int sem_unlink(const char *name);
/* psem/psem_unlink.cUnlink a POSIX named semaphore.On Linux, named semaphores are supported with kernel 2.6 or later, anda glibc that provides the NPTL threading implementation.
*/
#include <semaphore.h>
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{if (argc != 2 || strcmp(argv[1], "--help") == 0)usageErr("%s sem-name\n", argv[0]);if (sem_unlink(argv[1]) == -1)errExit("sem_unlink");exit(EXIT_SUCCESS);
}
3 信号量操作
POSIX 信号量也是一个整数并且系统不会允许其值小于 0。POSIX 信号量的操作具体包括:
- 修改信号量值的函数———sem_post()和 sem_wait(),一次只操作一个信号量。
- sem_post()和 sem_wait()函数只对信号量值加 1 和减 1。
3.1 等待一个信号量
sem_wait()函数会递减(减小 1)sem 引用的信号量的值。
#include <semaphore.h>
int sem_wait(sem_t *sem);
- 如果信号量的当前值大于 0,那么 sem_wait()会立即返回。
- 如果信号量的当前值等于 0,那么 sem_wait()会阻塞直到信号量的值大于 0 为止,当信号量值大于 0 时该信号量值就被递减并且 sem_wait()会返回。
- 如果一个阻塞的 sem_wait()调用被一个信号处理器中断了,那么它就会失败并返回 EINTR错误,不管在使用 sigaction()建立这个信号处理器时是否采用了 SA_RESTART 标记。(在其他一些 UNIX 实现上,SA_RESTART 会导致 sem_wait()自动重启。)
下面示例程序为 sem_wait()函数提供了一个命令行界面,稍后就会演示如何使用这个程序。
/* psem/psem_wait.cDecrease the value of a POSIX named semaphore.See also psem_post.c.On Linux, named semaphores are supported with kernel 2.6 or later, anda glibc that provides the NPTL threading implementation.使用 sem_wait()来递减一个 POSIX 信号量
*/
#include <semaphore.h>
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{sem_t *sem;if (argc < 2 || strcmp(argv[1], "--help") == 0)usageErr("%s sem-name\n", argv[0]);sem = sem_open(argv[1], 0);if (sem == SEM_FAILED)errExit("sem_open");if (sem_wait(sem) == -1)errExit("sem_wait");printf("%ld sem_wait() succeeded\n", (long) getpid());exit(EXIT_SUCCESS);
}
sem_trywait()函数是 sem_wait()的一个非阻塞版本。
#include <semaphore.h>
int sem_trywait(sem_t *sem);
如果递减操作无法立即被执行,那么 sem_trywait()就会失败并返回 EAGAIN 错误。
sem_timedwait()函数是 sem_wait()的另一个变体,它允许调用者为调用被阻塞的时间量指定一个限制。
#define _XOPEN_SOURCE 600
#include <semaphore.h>
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
如果 sem_timedwait()调用因超时而无法递减信号量,那么这个调用就会失败并返回ETIMEDOUT 错误。
abs_timeout 参数是一个结构,它将超时时间表示成了自新纪元到现在为止的秒数和纳秒数的绝对值。如果需要指定一个相对超时时间,那么就必须要使用 clock_gettime()获取 CLOCK_REALTIME 时钟的当前值并在该值上加上所需的时间量来生成一个适合在sem_timedwait()中使用的 timespec 结构。
3.2 发布一个信号量
sem_post()函数递增(增加 1)sem 引用的信号量的值。
#include <semaphore.h>
int sem_post(sem_t *sem);
如果在 sem_post()调用之前信号量的值为 0,并且其他某个进程(或线程)正在因等待递减这个信号量而阻塞,那么该进程会被唤醒,它的 sem_wait()调用会继续往前执行来递减这个信号量。如果多个进程(或线程)在 sem_wait()中阻塞了,并且这些进程的调度采用的是默认的循环时间分享策略,那么哪个进程会被唤醒并允许递减这个信号量是不确定的。(POSIX 信号量仅仅是一种同步机制,而不是一种排队机制。)
递增一个 POSIX 信号量对应于释放一些共享资源以供其他进程或线程使用。
示例程序为 sem_post()函数提供了一个命令行界面。
/* psem/psem_post.cIncrease the value of a POSIX named semaphore.See also psem_wait.c.On Linux, named semaphores are supported with kernel 2.6 or later, anda glibc that provides the NPTL threading implementation.使用 sem_post()递增一个 POSIX 信号量
*/
#include <semaphore.h>
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{sem_t *sem;if (argc != 2)usageErr("%s sem-name\n", argv[0]);sem = sem_open(argv[1], 0);if (sem == SEM_FAILED)errExit("sem_open");if (sem_post(sem) == -1)errExit("sem_post");exit(EXIT_SUCCESS);
}
3.3 获取信号量的当前值
sem_getvalue()函数将 sem 引用的信号量的当前值通过 sval 指向的 int 变量返回。
#include <semaphore.h>
int sem_getvalue(sem_t *sem, int *sval);
如果一个或多个进程(或线程)当前正在阻塞以等待递减信号量值,那么 sval 中的返回值将取决于实现。POSIX 允许两种做法:0 或一个绝对值等于在 sem_wait()中阻塞的等待者数目的负数。Linux 和其他一些实现采用了第一种行为,而另一些实现则采用了后一种行为。
注意在 sem_getvalue()返回时,sval 中的返回值可能已经过时了。依赖于 sem_getvalue()返回的信息在执行后续操作时未发生变化的程序将会碰到检查时、使用时(time-of-check、time-of-use)的竞争条件。
示例程序使用了 sem_getvalue()来获取名字通过命令行参数指定的信号量的值,然后在标准输出上显示该值。
/* psem_getvalue.cObtain the value of a POSIX named semaphore.On Linux, named semaphores are supported with kernel 2.6 or later, anda glibc that provides the NPTL threading implementation.使用 sem_getvalue()获取一个 POSIX 信号量的值
*/
#include <semaphore.h>
#include "tlpi_hdr.h"int
main(int argc, char *argv[])
{int value;sem_t *sem;if (argc != 2)usageErr("%s sem-name\n", argv[0]);sem = sem_open(argv[1], 0);if (sem == SEM_FAILED)errExit("sem_open");if (sem_getvalue(sem, &value) == -1)errExit("sem_getvalue");printf("%d\n", value);exit(EXIT_SUCCESS);
}
3.4 示例
下面的 shell 会话日志演示了如何使用本章中到目前为止给出的各个程序。首先创建了一个初始值为零的信号量,然后在后台启动一个递减这个信号量的程序。
$ ./psem_create -c /demo 600 0
$ ./psem_wait /demo &
[1] 3498686
后台命令将会阻塞,这是因为信号量的当前值为 0,从而无法递减这个信号量。接着获取这个信号量的值。
$ ./psem_getvalue /demo
0
从上面可以看到值 0。在其它系统上可能会看到值−1,表示存在一个进程正在等待这个信号量。
接着执行一个命令来递增这个信号量,这将会导致后台程序中被阻塞的 sem_wait()调用完成执行。
$ ./psem_post /demo
3498686 sem_wait() succeeded
[1]+ Done ./psem_wait /demo
(上面输出中的最后一行表明 shell 提示符会与后台作业的输出混合在一起。)
按下回车后就能看到下一个shell提示符,这也会导致shell报告已终止的后台作业的信息。接着在信号量上执行后续的操作。
$ ./psem_post /demo
$ ./psem_getvalue /demo
1
$ ./psem_unlink /demo
4 未命名信号量
未命名信号量(也被称为基于内存的信号量)是类型为 sem_t 并存储在应用程序分配的内存中的变量。通过将这个信号量放在由几个进程或线程共性的内存区域中就能够使这个信号量对这些进程或线程可用。
操作未命名信号量所使用的函数与操作命名信号量使用的函数是一样的(sem_wait()、sem_post()以及 sem_getvalue()等)。此外,还需要用到另外两个函数(这些函数不应该被应用到命名信号量上)。
- sem_init()函数对一个信号量进行初始化并通知系统该信号量会在进程间共享还是在单个进程中的线程间共享。
- sem_destroy(sem)函数销毁一个信号量。
未命名与命名信号量对比
使用未命名信号量之后就无需为信号量创建一个名字了,这种做法在下列情况中是比较有用的。
- 在线程间共享的信号量不需要名字。将一个未命名信号量作为一个共享(全局或堆上的)变量自动会使之对所有线程可访问。
- 在相关进程间共享的信号量不需要名字。如果一个父进程在一块共享内存区域中(如一个共享匿名映射)分配了一个未命名信号量,那么作为 fork()操作的一部分,子进程会自动继承这个映射,从而继承这个信号量。
- 如果正在构建的是一个动态数据结构(如二叉树),并且其中的每一项都需要一个关联的信号量,那么最简单的做法是在每一项中都分配一个未命名信号量。为每一项打开一个命名信号量需要为如何生成每一项中的信号量名字(唯一的)和管理这些名字设计一个规则(如当不再需要它们时就对它们进行断开链接操作)。
4.1 初始化一个未命名信号量
sem_init()函数使用 value 中指定的值来对 sem 指向的未命名信号量进行初始化。
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
pshared 参数表明这个信号量是在线程间共享 or 进程间共享。
- 如果 pshared 等于 0,那么信号量将会在调用进程中的线程间进行共享。在这种情况下,sem 通常被指定成一个全局变量的地址或分配在堆上的一个变量的地址。线程共享的信号量具备进程持久性,它在进程终止时会被销毁。
- 如果 pshared 不等于 0,那么信号量将会在进程间共享。在这种情况下,sem 必须是共享内存区域(一个 POSIX 共享内存对象、一个使用 mmap()创建的共享映射)中的某个位置的地址。信号量的持久性与它所处的共享内存的持久性是一样的。(通过其中大部分技术创建的共享内存区域具备内核持久性。但共享匿名映射是一个例外,只要存在一个进程维持着这种映射,那么它就一直存在下去。)由于通过 fork()创建的子进程会继承其父进程的内存映射,因此进程共享的信号量会被通过 fork()创建的子进程继承,这样父进程和子进程也就能够使用这些信号量来同步它们的动作了。
之所以需要 pshared 参数是因为下列原因。
- 一些实现不支持进程间共享的信号量。在这些系统上为 pshared 指定一个非零值会导致 sem_init()返回一个错误。Linux 直到内核 2.6 以及 NPTL 线程化技术的出现之后才开始支持未命名的进程间共享的信号量。
- 在同时支持进程间共享信号量和线程间共享信号量的实现上,指定采用何种共享方式是有必要的,因为系统必须要执行特殊的动作来支持所需的共享方式。提供此类信息还使得系统能够根据共享的种类来执行优化工作。
未命名信号量不存在相关的权限设置(即 sem_init()中并不存在在 sem_open()中所需的mode 参数)。对一个未命名信号量的访问将由进程在底层共享内存区域上的权限来控制。
与命名信号量一样,在地址通过传入 sem_init()的 sem 参数指定的 sem_t 变量的副本上执行操作的结果是未定义的,因此应该总是只在“最初的”信号量上执行操作。
示例程序
可以使用互斥体来保护一个存在两个线程访问同一个全局变量的临界区的程序。但下面程序使用一个未命名线程共享的信号量解决了同样的问题。
$ ./thread_incr_psem
glob = 20000000
/* thread_incr_psem.cUse a POSIX unnamed semaphore to synchronize access by two threads toa global variable.See also thread_incr.c and thread_incr_mutex.c.使用一个 POSIX 未命名信号量来保护对全局变量的访问
*/
#include <semaphore.h>
#include <pthread.h>
#include "tlpi_hdr.h"static int glob = 0;
static sem_t sem;static void * /* Loop 'arg' times incrementing 'glob' */
threadFunc(void *arg)
{int loops = *((int *) arg);int loc, j;for (j = 0; j < loops; j++) {if (sem_wait(&sem) == -1)errExit("sem_wait");loc = glob;loc++;glob = loc;if (sem_post(&sem) == -1)errExit("sem_post");}return NULL;
}int
main(int argc, char *argv[])
{pthread_t t1, t2;int loops, s;loops = (argc > 1) ? getInt(argv[1], GN_GT_0, "num-loops") : 10000000;/* Initialize a semaphore with the value 1 */if (sem_init(&sem, 0, 1) == -1)errExit("sem_init");/* Create two threads that increment 'glob' */s = pthread_create(&t1, NULL, threadFunc, &loops);if (s != 0)errExitEN(s, "pthread_create");s = pthread_create(&t2, NULL, threadFunc, &loops);if (s != 0)errExitEN(s, "pthread_create");/* Wait for threads to terminate */s = pthread_join(t1, NULL);if (s != 0)errExitEN(s, "pthread_join");s = pthread_join(t2, NULL);if (s != 0)errExitEN(s, "pthread_join");printf("glob = %d\n", glob);exit(EXIT_SUCCESS);
}
4.2 销毁一个未命名信号量
sem_destroy()函数将销毁信号量 sem,其中 sem 必须是一个之前使用 sem_init()进行初始化的未命名信号量。只有在不存在进程或线程在等待一个信号量时才能够安全销毁这个信号量。
#include <semaphore.h>
int sem_destroy(sem_t *sem);
当使用 sem_destroy()销毁了一个未命名信号量之后就能够使用 sem_init()来重新初始化这个信号量了。
一个未命名信号量应该在其底层的内存被释放之前被销毁。例如,如果信号量一个自动分配的变量,那么在其宿主函数返回之前就应该销毁这个信号量。如果信号量位于一个 POSIX共享内存区域中,那么在所有进程都使用完这个信号量以及在使用 shm_unlink()对这个共享内存对象执行断开链接操作之前应该销毁这个信号量。
在一些实现上,省略 sem_destroy()调用不会导致问题的发生,但在其他实现上,不调用sem_destroy()会导致资源泄露。可移植的应用程序应该调用 sem_destroy()以避免此类问题的发生。
5 POSIX 信号量与 Pthreads 互斥体对比
POSIX 信号量和 Pthreads 互斥体都可以用来同步同一个进程中的线程的动作,并且它们的性能也是相近的。然而互斥体通常是首选方法,因为互斥体的所有权属性能够确保代码具有良好的结构性(只有锁住互斥体的线程才能够对其进行解锁)。与之形成对比的是,一个线程能够递增一个被另一个线程递减的信号量。这种灵活性会导致产生结构糟糕的同步设计。
(正是因为这个原因,信号量有时候会被称为并发式编程中的“goto”。)
互斥体在一种情况下是不能用在多线程应用程序中的,在这种情况下信号量可能就成了一种首选方法了。由于信号量是异步信号安全的,因此在一个信号处理器中可以使用 sem_post()函数来与另一个线程进行同步。而信号量就无法完成这项工作,因为操作互斥体的 Pthreads 函数不是异步信号安全的。然而通常处理异步信号的首选方法是使用sigwaitinfo()(或类似的函数)来接收这些信号,而不是使用信号处理器,因此信号量比互斥体在这一点上的优势很少有机会发挥出来。
6 信号量的限制
SUSv3 为信号量定义了两个限制。
- SEM_NSEMS_MAX
这是一个进程能够拥有的 POSIX 信号量的最大数目。SUSv3 要求这个限制至少为 256。在 Linux 上,POSIX 信号量数目实际上会受限于可用的内存。 - SEM_VALUE_MAX
这是一个 POSIX 信号量值能够取的最大值。信号量的取值可以为 0 到这个限制之间的任意一个值。SUSv3 要求这个限制至少为 32767,Linux 实现允许这个值最大为 INT_MAX(在Linux/x86-32 上是 2147483647)。