http://blog.csdn.net/yourtommy/article/details/7345565
这篇博客是我看英文版原书时,翻译成中文,并测试了书中的代码。纯粹是为了加深理解和记忆。真正想学习的,还是阅读原书。
11.1 引言
我们在之前章里讨论了进程。我们知道了一个UNIX进程的环境,进程间的关系,和控制进程的方法。我们看到在相关进程间的可以有有限的共享发生。
在这章,我们将进一步研究进程内部来看我们如何能够使用多线程控制(或简单的线程)来在单个进程的环境内执行多个任务。在单个进程内的所有线程都可以访问相同进程组件,比如文件描述符和内存。
任何时间你尝试在多个用户之间共享单个资源,你必须处理一致性。我们在本章将看到可用的同步机制来避免多线程在它们共享的资源中发现不一致性。
11.2 线程概念
一个典型的UNIX进程可以被视为单线程控制:每个进程一次只做 一件事。有了多线程控制,我们可以设计我们的程序来在单个进程内同时做多件事,每个线程处理单个任务。这种方式可以有多种好处。
1、我们可以简化处理异步事件的代码,通进为每个事件类型分配一个线程。每个线程可以使用同步编程模型来处理它的事件。一个同步编程模型比一个异步的要简单得多。
2、多个进程必须使用由操作系统提供的复杂机制来共享内存和文件操作符,如我们将在15章和17章看到的。另一方面,线程自动拥有相同内存空间和文件描述符的访问。
3、一些问题可以被分割,以便整个程序的生产力可以被提升。有多个任务的单个进程隐式地序列化执行那些任务,因为只有一个线程控制。有了多进程控制,独立任务的处理可以交叉,通过给每个任务分配一个线程。只当不依赖于对方执行的处理时,两个任务才能交叉。
4、相似地,交互式程序可以提升响应时间,通过多线程来分开处理用户输入输出的部分和程序的其它部分。
一些人把多线程编程关联到多处理器系统。即使你的程序在单处理器上运行,一个多线程编程模型的好处也可以得到体现。一个程序可以用线程简化,而不管处理器的数量是多少,因为处理器的数量不会影响程序结构。更甚,只要你的程序在序列化任务时必须阻塞,你就仍可以看到响应时间和运行在多处理器上时的生产力的提升,因为一些线程可能可以在别的线程阻塞时运行。
一个线程由表示一个进程里的一个执行上下文所需的信息组成。这包括一个在进程里标识线程的线程ID、一组寄存器值、一个栈、一个调用优先级和策略、一个信号掩码、一个errno变量(1.7节)、和线程指定数据(12.6节)。在一个进程内的所有东西在进程里的线程间都可以共享,包括可执行程序的代码、程序的全局和堆内存、栈、和文件描述符。
我们将看到的线程接口是从POSIX.1-2001而来。线程接口,也被称为“pthreads”,表示“POSIX threads”,是POSIX.1-2001的可选特性。POSIX线程的特性测试宏是_POSIX_THREADS。应用可以在#ifdef使用它或在sysconf里使用_SC_THREADS常量来确定线程是否被支持。
11.3 线程标识(Threads Identification)
正如每个进程有一个进程ID,每个线程也有一个线程ID。不像在系统唯一的进程ID,线程ID只在它所属的进程上下文里是有意义的。
回想下一个进程ID,由pid_t数据类型表示,是一个非负整数。一个线程ID由pthread_t数据类型表示。实现可以使用一个结构体来表示pthread_t数据类型,所以可移植的实现不能把它们当成是整型。因此,一个函数被用来比较两个线程ID。
#include
int pthread_equal(pthread_t tid1, pthread_t tid2);
相等返回非0,否则返回0.
Linux使用一个无符号长整型来表示pthread_t数据类型。Solaris用一个无符号整型表示。FreeBSD和Mac OS X用一个指向pthread结构体的指针表示。
允许pthread_t为一个结构体的一个后果是没有可移植的方法来打印它的值。有时,在程序调试时打印线程ID很重要,但是其它时候通常不需要这样做。最坏时,这导致不可移植的调试代码,所以它没有很多限制。
一个线程可以获得它自己的线程ID,通过调用pthread_self函数。
#include
pthread_t thread_self(void);
返回调用线程的线程ID。
这个函数可以和pthread_equal一起使用,当一个线程需要识别由它的线程ID标签的数据结构时。例如,一个主线程可以把工作分配放到一个队列里,并使用线程ID来控制每个线程的工作。单个主线程把新的工作放到一个工作队列里。三个工作线程的线程池从队列删除工作。主线程通过在应该处理的每个工作里放置线程ID来控制工作分配,而不是让每个线程处理任何在队列头的工作。每个工作线程然后只删除标签为它自己的线程ID的工程。
11.4 线程创建(Thread Creation)
传统的UNIX进程模型每个进程只支持一个线程控制。概念上地,这和基于线程的模型是相同的,只是每个进程只由一个线程组成。有了pthreads,当一个程序运行时,它也作为带有单个线程控制的单个进程启动。当程序运行时,它的行为应该和传统的进程没有区别,直到它创建了更多的线程控制。额外的线程可以通过调用pthread_create函数创建。
#include
int pthread_create(pthread *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg);
成功返回0,失败返回错误号。
tidp所指的内存地址被设置为新创建的线程的线程ID,当pthread_create成功返回时。attr参数被用作定制各种线程属性。我们将在12.3节覆盖线程属性,但是现在,我们将把它设为NULL来创建一个有默认属性的线程。
新创建的线程在start_rtn函数的地址开始运行。这个函数接受单个参数,arg,它是无类型的指针。如果你需要传递多于一个的参数给start_rtn函数,那么你需要把它们存储在一个结构体里并在arg里传递这个结构体的地址。
当一个线程被创建时,没有保证哪个先运行:新创建的线程还是调用线程。新创建的线程有进程地址空间的访问,并继承了调用线程的浮点环境和信号掩码;然而,线程的待定信号集被清空。
注意pthread函数通常返回一个错误码,当它们失败时。它们不像其它POSIX函数一样设置errno。每个线程的errno的拷贝只被提供来与已有的使用它的函数兼容。使用线程,从函数返回错误码更简洁,因而把错误的范围局限在导致它的函数里,而不是依赖于一些作为函数副作用改变的全局数据。
尽管没有可移植的方法来打印线程ID,然而我们可以写一个小的测试程序来打印,来得到进程如何工作的一些观察。下面的代码创建一个线程并打印新线程和初始线程的进程ID和线程ID。
这个例子有两件怪事,需要在主线程和新线程之间处理竞争。(我们在本章后面学习处理这些的更好的方式。)第一个是主线程睡眠的需要。如果它不睡,主线程可能退出,因而终止整个进程,在新线程有机会运行前。这个行为取决于操作系统的线程实现和调度算法。
第二个怪事是新线程通常调用pthread_self来得到它的线程ID而不是从共享的内存读取或作为它线程开始全程的一个参数来获得。回想下pthread_create将返回新建线程的ID,通过第一个参数(tidp)。在我们的例子里,主线程把它存在ntid里,但是新线程不能安全地用它。如果新线程在主线程从pthread_create调用返回前运行,那么新线程将看到ntid的未初始化的内容而不是线程ID。
Solaris上的运行结果为:
$ ./a.out
main thread: pid 7225 tid 1 (0x1)
new thread: pid 7225 tid 4 (0x4)
正如我们期望的,两个线程有相同的进程ID,但是是不同的线程ID。
FreeBSD上的结果为:
$ ./a.out
main thread: pid 14954 tid 134529024 (0x804c000)
new thread: pid 14954 tid 134530048 (0x804c400)
正如我们期望的,两个线程有相同的进程ID。如果我们把线程ID看到十进制整数,那么值看起来很奇怪,但是如果我们用16进制看它们,那么它们看起来更有意义。正如我们早先注明的,FreeBSD使用线程数据结构体的指针表示它的线程ID。
我们期望Mac OS X和FreeBSD相似,但是主线程的线程ID和用pthread_create创建的线程的ID在不同的地址范围:
$ ./a.out
main thread: pid 779 tid 2684396012 (0xa000a1ec)
new thread: pid 779 tid 25166336 (0x1800200)
在Linux上运行相同的程序稍微有些不同:
$ ./a.out
new thread: pid 6628 tid 1026 (0x402)
main thread: pid 6626 tid 1024 (0x400)
Linux线程ID看起来更合理,但是进程ID不匹配。这是Linux线程实现的人工制品,这里clone系统调用被用来实现pthread_create。clone系统调用创建一个子进程,可以共享它父进程执行上下文的可配置的量,比如文件描述符和内存。
还要注意主线程的输出在创建的线程的输出前出现,除了Linux。这证明了我们不能对线程如何被调度作任何假设。(我机器上的Linux的结果为:
$ ./a.out
main thread: pid 2381 tid 3077965504 (0xb77606c0)
new thread: pid 2381 tid 3077962608 (0xb775fb70)。)
11.5 线程终止(Thread Termination)
如果进程里的任何线程调用exit、_Exit或_exit,那么整个进程会终止。相似的,当默认的动作被设为终止进程时,一个发送给一个线程的信号将终止整个进程(我们在12.8节讨论更多关于信号和线程之间的交互)。
单个线程可以用三种方式退出,从而停止它的控制流,而不终止整个进程。
1、线程可以简单地从开始例程退出。返回值是线程的退出码。
2、线程可以被同一进程的其它线程取消。
3、线程可以调用pthread_exit。
#include
void pthread_exit(void *rval_ptr);
rval_ptr是无类型指针,和传入启动例程的单个参数相似。这个指针对进程里的其它线程可用,通过调用pthread_join函数。
#include
int pthread_join(pthread_t thread, void **rval_ptr);
成功返回0,失败返回错误码。
调用线程将阻塞,直到指定的线程调用pthread_exit、从它的启动例程退出、或被取消。如果线程简单地从它的启动例程返回,那么rval_ptr将包含返回码。如果线程被取消,那么由rval_ptr指定的内存地址被设为PTHREAD_CANCELED。
通过调用pthread_join,我们自动把一个线程置为分离状态(马上讨论)以便它的资源可以被恢复。如果线程已经在分离状态了,那么调用pthread_join失败,返回EINVAL。
如果我们不对一个线程的返回值感兴趣,我们可以把rval_ptr设为NULL。在这种情况下,调用pthread_join允许我们等待指定的线程,但不得到线程的终止状态。
下面的代码展示了如何获取一个终止线程的退出码。
正如我们可以看到的,当一个线程通过调用pthread_exit退出或简单从启动例程返回时,退出状态可以被另一个线程通过调用pthread_join来获得。
传递给pthread_create和pthread_exit的无类型的指针可以被用来传递比单个值更多。这个指针可以用来传递一个包含更复杂信号的结构体的地址。小心当调用者完成时用作结构体的指针仍然有效。如果结构体在调用者的栈上分配,那么内存内容可能在结构体被使用时已经改变了。例如,如果一个线程在它的栈上分配了一个结构体并把它的指针传递给pthread_exit,那么然后这个栈可能被销毁而它的内存被重用为其它东西,在pthread_join尝试使用它时。
下面的代码展示了使用一个自动变量(在栈上分配)作为pthread_exit的参数的问题。
当然,根据内存架构、编译器和线程库的实现,结果会有所不同。
正如我们可以看到的,结构体的内容(在线程tid1的栈上分配的)在主线程可以访问这个结构体时已经改变了。注意第二个线程(tid2)的栈如何覆写掉第一个线程的栈。为了解决这个问题,我们可以使用一个全局结构体或用malloc分配这个结构体。
一个线程可以请求同一进程内的另一个线程取消,通过调用pthread_cancel函数。
#include
int pthread_cancel(pthread_t tid);
成功返回0,失败返回错误号。
在默认情况下,pthread_cancel将导致tid指定的线程表现得好像它已经调用参数为PTHREAD_CANCELED的pthread_exit。然而,一个线程可以选择忽略或控制它如何被取消。我们将在12.7节深入讨论。注意pthread_cancel不等待线程终止。它只是发出请求。
一个线程可以安排当它退出时要调用的函数,和atexit函数(7.3节)安排在进程退出时所调用的函数的方法相似。这个函数被称为线程清理处理机。一个线程可以建立多个清理处理机。处理机被保存在一个栈里,这意味着它们以被注册的相反的顺序执行。
#include
void pthread_cleanup_push(void (*rtn)(void *), void *arg);
void pthread_cleanup_pop(int execute);
pthread_cleanup_push函数安排清理函数,rtn,随着单个参数,arg,而被调用,当线程执行以下某个动作时:
1、调用pthread_exit;
2、响应一个取消请求;
3、使用非0execute参数调用pthread_cleanup_pop。
如果execute参数被设为0,那么清理函数不会被调用。在各种情况下,pthread_cleanup_pop都删除由最后的pthraed_cleanup_push调用建立的清理处理机。
这些函数的一个限制是,因为它们可以被实现为宏,所以它们必须在线程的一些范围内成对地使用。pthread_cleanup_push的宏定义可以包括一个“{”字符,这种情况下要匹配pthread_cleanup_pop定义里的“}”字符。
下面的代码展示如何使用线程清理处理机。尽管这个例子有点勉强,但它展示了所使用的机制。注意尽管我们从未想要传一个非0参数给线程启动例程,但我们仍需要匹配pthread_cleanup_pop和pthread_cleanup_push;否则,程序可能不能编译。
从输出我们可以看到两个线程都恰当地启动并退出,但是只有第二个线程的清理处理机被调用。因而,如果线程从它的主例程中返回,那么它的清理处理机不会被调用。还有注意清理处理机以它们被安装里的相当顺序被调用。
到现在为止,你应该开始看到线程函数和进程函数之间的相似之处了。下表总结了这些相似的函数:
进程原始例程 | 线程原始例程 | 描述 |
---|---|---|
fork | pthread_create | 创建一个新的控制流 |
exit | pthread_exit | 从已有的控制流中退出 |
waitpid | pthread_join | 从控制流得到退出状态 |
atexit | pthread_cancel_push | 注册在控制流退出时调用的函数 |
getpid | pthread_self | 得到控制流的ID |
abort | pthread_cancel | 请求控制流的异常退出 |
默认时,一个线程的终止状态被保留,直到pthread_join为那个线程调用。一个线程的底下存储可以在终止时立即回收,如果线程被分离。当一个线程被分离时,pthread_join函数不能用来等待它的终止状态。一个未分离线程而调用的pthread_join会失败,返回EINVAL。我们可以调用pthread_detach来分离一个线程。
#include
int pthread_detach(pthread_t tid);
成功返回0,失败返回错误号。
正如我们将在下章看到的,我们可以创建一个处在分离状态的线程,通过仅修改传入pthread_create的线程属性。
11.6 线程同步
当多个线程控制共享相同的内存时,我们需要确保每个线程看到它数据的一致的外观。如果每个线程使用其它线程不读或修改的变量,就不会有一致性的问题。相似地,如果一个变量是只读的,当多个线程同时读它时也是不会有一致性问题的。然而,当一个线程可以修改一个变量而其它线程可以读或修改,那么我们需要同步这些线程来确保在访问变量内存内容时它们不会使用一个无效的值。
当一个线程修改一个变量时,其它线程在读这个变量的值时可能潜在地看到不一致性。在修改需要多于一个内存周期的处理器架构上,这可能在内存的读与内存写周期交叉时发生。当然,这个行为取决于架构,但是可移植的程序不能对所使用的处理器架构作任何假设。
下面一个两个线程同时读写同一个变量的假定的例子:线程A读取变量然后写入一个新值,但是写操作耗费两个内存周期。如果线程B在这两个写周期之间读取相同的值,那么它将看到一个不一致的值。
为了解决这个问题,线程必须使用一个锁,它将允许只有一个线程在同一时刻访问这个变量。上面的例子里,如果线程B想读取这个变量,它需要一个锁。相似地,当线程A更新变量时,它需要相同的锁。因此,线程B将不能读取变量,直到线程A释放这个锁。
你也需要同步两个或多个同时尝试修改相同变量的线程。考虑你增加一个变量的情况。增加操作通常分解为三个步骤:
1、把内存地址读入寄存器;
2、增加寄存器里的值;
3、把新值写到内存地址。
如果两个线程尝试几乎同时增加相同的变量,而不与对方同步,那么结果可能是不一致的。你最后可能得到一个比之前大一或二的值,取决于当第二个线程开始操作时所观察到的值。如果第二个线程在第一个线程执行步骤3时执行步骤1,那么第二个线程将读取到和第一个线程相同的初始值,增加它,再写回,而没有干净的结果。
如果修改是原子的,那么就不会有竞争。在前一个例子里,如果增加只花费一个内存周期,那么不会有竞争。如果我们的数据总是在相继一致,那么我们不需要额外的同步。我们的操作是相继一致的,当多个进程不能观察到我们数据里的不一致性时。在当代计算机系统里,内存访问花费多个总线周期,而多处理器通常在多个处理器之间交叉总线周期,所以我们不被保证我们的数据是相继一致的。
在一个相继一致的环境,我们可以把对我们数据的修改解释为一列由运行线程执行的操作步骤。我们可以说诸如“线程A增加变量,然后线程B增加这个变量,所以它的值比原来大二”或“线程B增加变量,然后线程A增加这个变量,所以它的值比原来大二”之类的话。两个线程可能的顺序不会导致变量任何其它的值。
除了计算机架构,竞争也会根据我们程序使用变量的可能看到不一致性的方式发生。例如,我们可能增加一个变量,然后基于这个值作决定。增加步骤和作决定的步骤不是原子的,所以这给不一致性的发生打开了一个间隙。
互斥体(Mutexes)
我们可以保护我们的数据并确保某时刻只有一个线程访问,通过使用pthreads的mutual-exclusion接口。一个mutex基本上是一个锁,我们在访问一个共享资源前设置它(lock)并在完成时释放它(unlock)。当它被设置时,任何其它尝试设置它的线程都会阻塞,直到我们释放了它。如果多于一个线程在我们解锁互斥体时处于阻塞状态,那么所有阻塞在该锁上的线程都变得可运行,第一个运行的线程能够设置这个锁。其它的将看到这个互斥体仍然被锁而回去等待它变得重新可用。这种方式,某一时刻只有一个线程进程。
这个彼此互斥机制只在我们把所有线程设计为遵守相同数据访问规则时才工作。操作系统并不为我们序列化数据的访问。如果我们允许一个线程不先申请锁而访问一个共享资源,那么即使其它线程都这么做不一致性仍然会发生。
一人互斥体变量由pthread_mutex_t数据类型表示。在我们使用一个mutex变量之前,我们必须首先初始化它,通过把它设置为常量PTHREAD_MUTEX_INITIALIZER(只针对于静态分配的互斥体)或调用pthread_mutex_init。如果我们动态地分配互斥体(例如通过调用malloc),那么我们需要在释放内存前调用pthread_mutex_destroy。
#include
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
成功返回0,失败返回错误码。
为了使用默认属性初始化一个互斥体,我们把attr设置为NULL。我们将在12.4节讨论非默认的互斥体属性。
为了锁住一个互斥体,我们调用pthread_mutex_lock。如果互斥体已经被锁了,那么调用线程将阻塞,直到互斥体被解锁。要解锁一个互斥体,我们调用pthread_mutex_unlock。
#include
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
成功返回0,失败返回错误号。
如果一个线程不能容许阻塞,那么它可以使用pthread_mutex_trylock来有条件地锁这个互斥体。如果互斥体在pthread_mutex_trylock被调用时已经解锁,那么pthread_mutex_trylock会不阻塞地锁住这个互斥体并返回0。否则,pthread_mutex_trylock将会失败,返回EBUSY而不锁住这个互斥体。
下面的代码演示了一个用来保护一个数据结构体的互斥体。当多个线程需要访问一个动态分配的对象时,我们可以在对象里嵌入一个引用计数来保证我们在所有线程使用它完毕前不会释放它的内存。
在使用这个对象前,线程被期望为它加上一个引用计数。当它们完成时,它们必须释放这个引用。当最后的引用被释放时,对象的内存被释放。
死锁的避免(Deadlock Avoidance)
一个线程如果尝试两次锁住同一个互斥体那么它将把自己死锁,但是有更不明显的方式来创建互斥体的死锁。例如,当我们在程序里使用多于一个的互斥体,如果我们允许一个线程得到一个互斥体而在尝试锁住第二个互斥体时阻塞,同时另一个线程得到第二个互斥体而尝试锁住第一个互斥体,那么一个死锁会发生。没有任何一个线程可以进行,因为两者都需要对方得到的资源,所以我们有了一个死锁。
死锁可以通过小心地控制互斥体锁的顺序来避免。例如,假设你有两个互斥体,A和B,你需要同时锁住它们。如果所有的线程总是在互斥体B之前锁住互斥体A,则在这两个互斥体上不会有死锁发生(但是你仍可以死锁在别的资源上)。相似地,如果所有线程总是在互斥体A之前锁住互斥体B,也没有死锁发生。你只当尝试以别的线程的相反顺序锁住互斥体时才会有潜在的死锁。
有时,一个应用的架构让你很难应用一个锁的顺序。如果有足够的锁和数据结构,你可用的函数仍不能被建模为适合一个简单的层次结构,那么你将必须尝试一些其它的方法。在这种情况下,你可能可以释放你的锁然后在稍后再次尝试。你可以使用pthread_mutex_trylock接口来避免这种情况下的死锁。如果你已经得到了锁,而pthread_mutex_trylock成功了,那么你可以继续进行。然而,如果它不能得到这个锁,你可以释放你已经有的锁,清理,稍后再次尝试。
在这个例子里,我们更新上面的代码来展示两个互斥体的使用。我们通过确保当需要同时获取两个锁时,我们总是以相同的顺序锁住它们,来避免死锁。第二个互斥体保护了一个我们用来跟踪foo数据结构体的哈希列表。在foo结构体里的f_lock互斥体保护了foo结构体里其它域的访问。
foo_find函数锁住哈希列表锁并查找请求的结构体。如果它被找到,那么我们增加引用计数并返回该结构体的指针。注意我们遵守锁的顺序,通过在foo_hold锁住foo结构体的f_lock互斥体之前,在foo_find里锁住哈希列表锁。
现在随着两个锁,foo_rele函数更复杂了。如果这是最后的引用,那么我们需要解锁结构体互斥体以便我们可以请求哈希列表锁,因为我们必须从哈希列表中删除这个结构体。然后我们申请结构体的互斥体。因为我们可能已经阻塞在上次得到结构体互斥体时,所以我们需要重新检查条件来看我们是否仍需要释放这个结构体。如果另一个线程发现这个结构体并给它加上一个引用,当我们为了锁的顺序而阻塞的时候,那么我们简单地减少引用计数,解锁所有的东西,然后返回。
这个锁是复杂的,所以我们需要重新审视我们的设计。我们也可以仔细简化事物,通过使用哈希列表锁来保护结构体引用计数。结构体互斥体也以用来保护foo结构体里的其它所有东西。下面的代码反应了这个改变。
读写锁(Reader-Writer Locks)
读写锁和互斥体类似,除了它允许更高程度的并行化。使用互斥体,状态只有锁或无锁,且一次只有一个线程可以锁。一个读写锁有三个状态:在读模式上锁,在写模式上锁,和无锁。一次只有一个线程可以拿到写模式的读写锁,但多个线程可以同时拿到一个读模式的读写锁。
当一个读写锁被写锁时,所有尝试锁它的线程都被阻塞,直到它被解锁。当一个读写锁被读锁时,所有尝试以读模式锁它的线程都可以得到访问,但任何尝试以写模式锁它的线程都会阻塞,直到所有线程释放它们的读锁。尽管实现不同,读写锁通常阻塞额外的读者,如果一个锁以读模式被得到,而一个尝试以写模式申请锁的线程正被阻塞。这避免了恒定的读者流饿死了等待的写者。
读写锁非常适合这种情况:数据结构被读比被修改更经常。当一个读写锁以写模式被得到,它保护的数据结构可以安全地被修改,因为一次只有一个线程可以得到写模式的锁。当读写锁以读模式被得到,那么它保护的数据结构可以被多个线程读,只要线程首先得到这个读模式的锁。
读写锁也被称为共享互斥(shared-exclusive)锁。当一个读写所被读锁时,它可以说是以共享模式被锁。当它被写锁时,它可以说是以互斥模式被锁。
和互斥体一样,读写锁并须在使用前初始化,并在释放它们底层内存时被销毁。
#include
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
两者成功返回0,失败返回错误号。
一个读写锁通过调用phthread_rwlock_init初始化。我们可以传递一个空指针作为attr,如果我们想这个读写锁有默认的属性。我们在12.4节讨论读写锁的属性。
在释放读写锁后面的内存之前,我们必须调用pthread_rwlock_destroy来清理它。如果pthread_rwlock_init为读写锁分配了任何资源,那么pthread_rwlock_destroy会释放这些资源。如果我们不首先调用pthread_rwlock_destroy再释放一个读写锁后面的内存,那么任何分配给这个锁的资源都会被丢失。
为了以读模式锁住一个读写锁,我们调用pthread_rwlock_rdlock。为了写锁一个读写锁,我们调用pthread_rwlock_wrlock。不管我们如何锁一个读写锁,我们可以调用pthread_rwlock_unlock来解锁它。
#include
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
三者成功返回0,失败返回错误号。
实现可以在一个读写锁可以以共享模式被锁的次数上加个限制,所以我们需要检查pthread_rwlock_rdlock的返回值。即使pthread_rwlock_wrlock和pthread_rwlock_unlock有错误返回,如果我们恰当地设计了我们的锁那么我们仍不需要检查它们。唯一返回的错误定义在当我们不恰当使用它们时,比如使用一个未初始化的锁,或当我们尝试申请已经拥有的锁而可能死锁时。
SUS也定义了读写锁原始例程的条件版本。
#include
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
两者成功返回0,失败返回错误号。
当锁被得到时,这些函数返回0。否则,它们返回错误EBUSY。这些函数可以用在遵守一个锁层次也不足以避免一个死锁的情况,如我们之前讨论的。
下面的代码演示了读写锁的使用。一个工作请求队列被单个读写锁保护。这个例子展示了一个可能的实现:多个工作线程获取由单个主线程分配给它们的工作。
工作线程只把那些匹配它们线程ID的工作从队列中移除。因为工作结构体一次只被一个线程使用,所以它们不用额外的锁。
条件变量(Condition Variables)
条件变量是线程可用的另一个同步机制。条件变量提供了为线程集结的位置。当和互斥体使用时,条件变量允许线程以无竞争的方式等待任意条件的发生。
条件本身被一个互斥体保护。一个线程必须首先锁住互斥体来改变条件状态。其它线程将不会注意到改变,直到它们申请这个互斥体时,因为互斥体必须被锁住才能得到条件的值。
在条件变量被使用前,它必须首先被初始化。一个条件变量,由pthread_cond_t数据类型表示,可以用两种方式初始化。我们可以把常量PTHREAD_COND_INITIALIZER赋给一个静态分配的变量,但是如果条件变量是动态分配的,我们可以使用pthread_cond_init函数来初始化它。
我们可以使用pthread_cond_destroy函数来反初始化一个条件变量,在释放它底下的内存前。
#include
int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
两者成功返回0,失败返回错误号。
除非你必须用非默认属性创建一个条件变量,否则pthread_cond_init的attr参数可以被设置为NULL。我们将在12.4节讨论条件变量的属性。
我们使用pthread_cond_wait来等待一个条件为真。一个变体被提供来返回一个错误号,如果条件在指定的时间内没有被满足。
#include
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
成功返回0,失败返回错误号。
传递给pthread_cond_wait的互斥体保护这个条件。调用者锁住它传递给这个函数,它然后被调用线程自动放在等待这个条件的线程列表上,并解锁这个互斥体。这关闭了条件被检查和线程等待条件改变时睡眠之间的时间间隙,以便线程不会错过条件的某个改变。当pthread_cond_wait返回时,互斥体会再次被锁住。
pthread_cond_timedwait函数和pthread_cond_wait函数一样工作,只是有额外的超时。这个timeout值指定我们将等待多久。它由timespec结构体指定,这里一个时间值由秒数和部分秒数示。部分秒数以纳秒为单位表示:
struct timespec {
time_t tv_sec; /* seconds */
long tv_nsec; /* nanoseconds */
};
使用这个结构体,我们需要指定我们愿意等待多久,作为一个绝对时间而不是相对时间。例如,如果我们愿意等待3分钟,我们需要把现在+3分钟翻译到timespec结构体里,而不是把3分钟翻译进去。
我们可以使用gettimeofday(6.10节)来得到用timeval结构体表示的当前时间,并把它翻译到timespec结构体里。为了得到timeout值的绝对时间,我们可以使用下面的函数:
void
makdtimeout(struct timespec *tsp, long minutes)
{
struct timeval now;
/* get the current time */
gettimeofday(&now);
tsp->tv_sec = now.tv_sec;
tsp->tv_nsec = now.tv_usec * 1000; /* usec to nsec */
/* add the offset to get timeout value */
tsp->tv_sec += minutes * 60;
}
如果timeout超时而条件没有发生,pthread_cond_timedwait将重新申请互斥体并返回错误ETIMEDOUT。当从一个pthread_cond_wait或pthread_cond_timedwait的成功调用里返回时,一个线程需要重新评估这个条件,因为另一个线程可能已经运行并已改变了这个条件。
有两个函数来通知线程一个条件已经被满足。pthread_cond_signal函数将唤醒等待在一个条件上的某个线程,而pthread_cond_broadcast函数将唤醒等待在一个条件上的所有线程。
POSIX规定允许pthread_cond_signal的实现唤醒不只一个线程,来让实现更加简单。
#include
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
成功返回0,失败返回错误号。
当我们调用pthread_cond_signal或pthread_cond_broadcast时,我们被告知是向线程还是条件发送信号。我们必须小心只在改变条件状态之后才发送信号给线程。
下面的代码展示了如何一起使用条件变量和互斥体来同步线程的一个例子:
11.7 总结
在这章我们介绍了线程的概念和讨论了可用来创建和销毁它们的POISX.1原始例程。我们也介绍了线程同步的问题。我们讨论了在三个基本的同步进制:互斥体、读定锁和条件变量,我们看到了如何使用它们来保护共享资源。
12.1 引言
在11章,我们学到了关于线程和线程同步的基础。在这章,我们将学习控制线程行为的细节。我们将看到线程属性和同步原始属性,我们在前一章忽略了它们来使用默认行为。
随后我们将看到线程如何可以保持数据对于同一进程内的其它线程的私有性。然后我们将在本章末看一些基于进程的系统调用如何和线程交互。
12.2 线程限制
我们在2.5.4节讨论过sysconf函数。SUS定义了和线程操作相同的几种限制,我们并没有在第2章的表里显示。和其它系统限量一样,线程限量可以用sysconf查询。下表总结了这些限量。
限量名 | 描述 | 名字参数 |
---|---|---|
PTHREAD_DESTRUCTOR_ITERATIONS | 当一个线程退出时一个实现将尝试销毁线程相关数据的最大次数。 | _SC_THREAD_DESTRUCTOR_ITERATIONS |
PTHREAD_KEYS_MAX | 一个进程可以创建的关键字的最大数量。 | _SC_THREAD_KEYS_MAX |
PTHREAD_STACK_MIN | 可以作为一个线程栈的最少字节数。 | _SC_THREAD_STACK_MIN |
PTHREAD_THREADS_MAX | 一个进程可以创建的最大线程数 | _SC_THREAD_THREADS_MAX |
和sysconf报告的其它限量一样,这些限量的使用用于促进应用在不同操作系统实现的可移植性。例如,如果你的应用要求你为你管理的每个文件创建四个线程,那么你可能必须你可以并发管理的文件数,如果系统不让你创建足够的线程。
下表展示了本文四个实现的线程限量值。当实现没有定义对于的sysconf符号(以_SC_开头)时,“无符号”被列出。如果实现的限量不确定,“无限制”被列出。尽管如此,这并不表示这个值没有限制。一个“不支持”的项表示实现定义了对应的sysconf限量符号,但是sysconf函数不识别它。
限量 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
---|---|---|---|---|
PTHREAD_DESTRUCTOR_INTERATIONS | 无符号 | 不支持 | 无符号 | 无限制 |
PTHREAD_KEYS_MAX | 无符号 | 不支持 | 无符号 | 无限制 |
PTHREAD_STACK_MIN | 无符号 | 不支持 | 无符号 | 4096 |
PTHREAD_THREADS_MAX | 无符号 | 不支持 | 无符号 | 无限制 |
12.3 线程属性
在十一章里我们调用过pthread_create的所有例子里,我们传递一个空指针而不是一个pthread_attr_t结构体的指针。我们可以使用pthread_attr_t结构体来修改默认行为,并把这些属性和我们创建的线程关联起来。我们使用pthread_attr_init函数来初始化pthread_attr_t结构体。在调用pthread_attr_init后,pthread_attr_t结构体包含了实现支持的所有线程属性的默认值。要改变单个属性,我们需要调用其它函数,本节后面就描述。
#include
int pthread_attr_init(pthread_attr_t *attr);
int pthread_attr_destroy(pthread_attr_t *attr);
成功返回0,失败返回错误号。
为了反初始化一个pthread_attr_t结构体,我们调用pthread_attr_destroy。如果pthread_attr_init的一个实现为属性对象分配了任何动态内存,那么pthread_attr_destroy会释放那个内存。此外,pthread_attr_destroy将会用无效值初始化属性对象,所以如果它被误用,pthread_create会返回一个错误。
pthread_attr_t结构体对应用透明。这意味着应用不被支持知道任何它内部的结构,因此促进应用的移植性。根据这个模型,POSIX.1定义了独立的函数来查询和设置每个属性。
POSIX.1定义的线程属性在下表中汇总。POSIX.1定义了实现线程选项里的补充属性,但是我们不在这里讨论。在下表,我们也展示了哪些平台支持各个线程属性。如果属性通过废弃的接口可访问,我们在表项里显示ob。
名字 | 描述 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
---|---|---|---|---|---|
detachstate | 分离的线程属性 | * | * | * | * |
guardsize | 在线程栈末尾的保卫缓冲的字节尺寸 | * | * | * | |
stackaddr | 线程栈的最低地址 | ob | * | * | ob |
stacksize | 线程栈的字节尺寸 | * | * | * | * |
在11.5节,我们介绍了分享线程的概念。如果我们不再对已有线程的终止状态感兴趣,那么我们可以使用pthread_detach来允许操作系统在线程退出时回收这个线程的资源。
如果我们在创建这个线程的时候知道我们不需要线程的终止状态,那么我们可以安排线程以分离状态启动,通过修改pthread_attr_t结构体里的detachstate属性。我们可以使用pthread_attr_setdetachstate函数来设置detachstate线程属性为两个合法值中的某一个:PTHREAD_CREATE_DETACHED来以分离状态启动线程,或PTHREAD_CREATE_JOINABLE来正常启动线程。所以它的终止状态可以被应用得到。
#include
int pthread_attr_getdetachstate(const pthread_attr_t *restrict attr, int *detachstate);
int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
成功返回0,失败返回错误号。
我们可以调用pthread_attr_getdetachstate来得到当月的detachstate属性。被第二个参数指向的整型被设置为PTHREAD_CREATE_DETACHED或PTHREAD_CREATE_JOINABLE,取决于给定的pthread_attr_t结构体的这个属性的值。
下面的代码展示了一个可以用来以分离状态创建一个线程的函数:
对于线程栈属性的支持对于POSIX操作系统是可选的,但是如果系统遵守XSI则是必需的。在编译器,你可以检查你的系统是否支持每个线程栈属性,使用_POSIX_THREAD_ATTR_STACKADDR和_POSIX_THREAD_ATTR_STACKSIZE符号。如果某个被定义了,那么系统支持对应的线程栈属性。你也可以在运行时检查,通过使用_SC_THREAD_ATTR_STACKADDR和_SC_THREAD_ATTR_STACKSIZE参数给sysconf函数。
POSIX.1定义了几个操作线程栈属性的接口。两个更老的函数,pthread_attr_getstackaddr和pthread_attr_setstackaddr,在SUS的版本3里被标记为废弃的,尽管许多pthreads实现仍提供它们。查询和修改线程栈属性的最好方法是使用更新的函数pthread_attr_getstack和pthread_attr_setstack。这些函数去除了更老的接口定义里表现中的歧义。
#include
int pthread_attr_getstack(const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t *restric stacksize);
int pthread_attr_setstack(pthread_attr_t *attr, void *statckaddr, size_t *stacksize);
两者成功返回0,失败返回错误号。
这两个函数被用来同时管理stackaddr和stacksize线程属性。
对于一个进程,虚拟地址空间量是固定的。因为只有一个栈,它的尺寸通常不是问题。对于线程,同一个虚拟地址空间的量必须被所有线程栈共享。你可能必须减少你默认的线程栈尺寸,如果你的应用使用很多线程,以至栈的总尺寸超过可用的虚拟地址空间。另一方面,如果你的线程调用分配巨大自动变量的函数或调用很深的栈框架的函数,那么你要比需要比默认栈尺寸更多。
如果你为线程栈用完了虚拟进程空间,那么你调用malloc或mmap(14.9节)来为一个代替的栈分配空间,并使用pthread_attr_setstack来改变你创建的线程的栈位置。stackaddr指定的地址是用于线程栈的最低可寻址地址,根据处理器架构对齐到合适的边界。
stackaddr线程属性被定义为栈的最低内存地址。然而,这不必是栈的开始。如果对于给定的处理器架构栈从高位地址向低位地址增长,那么stackaddr线程属性将是栈的末尾而不是开头。
pthread_attr_getstackaddr和pthread_attr_setstackaddr的缺陷是stackaddr参数是未详细说明的。它可能已经被解释为栈的开头或作为栈使用的内存扩展的最低内存地址。在栈从高位内存地址向低位地址住下增长的架构上,如果stackaddr参数是栈的最低内存地址,那么你需要知道栈的尺寸来决定栈的开头。pthread_attr_getstack和pthread_attr_setstack函数更正了这个缺点。
一个应用也可以用pthread_attr_getstacksize和pthread_attr_setstacksize函数来得到和设置stacksize线程属性。
#include
int pthread_attr_getstacksize(const pthread_attr_t *restrict attr, size_t *restrict stacksize);
int pthread_attr_setstackszie(pthread_attr_t *attr, size_t stacksize);
两者成功返回0,失败返回错误号。
当你想改变默认栈大小而不想自己处理分配线程栈时,pthread_attr_setstacksize函数很有用。
guardsize线程属性控制了线程栈末尾之后的内存扩展的尺寸,来保护栈溢出。默认情况下,这被设置为PAGESIZE字节。我们可以设置guardsize线程属性为0来禁止这个特性:在这种情况下不会有保卫缓冲被提供。同样,如果你改变stackaddr线程属性,那么系统假定我们将管理自己的栈并禁止栈保卫缓冲,就好像我们设置guardsize线程属性为0一样。
#include
int pthread_attr_getguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
成功返回0,失败返回错误号。
如果guardsize线程属性被修改,操作系统可以把它四舍五入为页尺寸的倍数的一个整型。如果线程的栈指针溢出到保卫区域,那么应用将会收到一个错误,可能是一个信号。
SUS定义了几种其它的线程属性作为实时线程选项的一部份。我们将不在这里讨论。
更多的线程属性
线程有其它不被pthread_attr_t结构体表示的属性。
1、可取消状态(12.7节);
2、可取消类型(12.7节);
3、并发等级。
并发等级控制了内核线程或进程的数量,在它们之上用护级线程被映射。如果一个实现在内核级线程和用户级线程之间保持一对一的映射,那么改变并发等级将不会有效果,因为所用用户级线程都可能被调度。但是,如果实现在内核进程或线程之上多元化用户级线程,那么你可能可以通过增加在给定时间可以运行的用户级线程数量,来提升性能。pthread_setconcurrency函数可以用来提供给系统所需并发等级的提示。
#include
int pthread_getconcurrency(void);
返回当前并发级数。
int pthread_setconcurrency(int level);
成功返回0,失败返回错误号。
pthread_getconcurrency函数返回当前并发等级。如果操作系统正控制着并发级数(也就是说,之前没有调用过pthread_setconcurrency),那么pthread_getconcurrency将返回0。
由pthread_setconcurrency指定的并发级数只是对系统的一个提示。没有保证所请求的并发级数会被满足。你可以告诉系统你想它自己决定使用的并发级数,通过传递一个0的级数。因此,一个应用可以撤消前一个非0值的pthread_setconcurrency调用的影响,通过0的级数再次调用它。
12.4 同步属性(Synchronizaiton Attributes)
正各线程有属性一样,它们的同步对象也有。在本节,我们讨论互斥体、读写锁和条件变量的属性。
互斥体属性
我们使用pthread_mutexattr_init来初始化一个phread_mutexattr_t结构体,和pthread_mutexattr_destroy来销毁它。
#include
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
成功返回0,失败返回错误号。
pthread_mutexattr_init函数将用默认互斥体属性初始化pthread_mutexattr_t结构体。两个感兴趣的属性是进程共享(process-shared)属性和类型(type)属性。在POSIX.1里,进程共享属性是可选的:你可以检查_POSIX_THREAD_PROCESS_SHARED符号是否定义来测试一个平台是否支持它。你也可以调用参数为_SC_THREAD_PROCESS_SHARED给sysconf函数来在运行期测试。尽管这个可选项不需要POSIX操作系统提供,然而SUS要求XSI操作系统必须支持这个选项。
在一个进程里,多线程可以访问同一个同步对象。这是默认行为,和11章看到的一样。在这种情况下,进程共享互斥体属性被设为PTHREAD_PROCESS_PRIVATE。
正如我们将在14和15章看到的,存在机制允许独立的进程把相同的内存扩展映射到它们的独立地址空间里。访问多进程的共享数据通常需要同步,和访问多线程的共享数据一样。如果进程共享互斥体属性被设为PTHREAD_PROCESS_SHARED,在多进程间共享的内存扩展里分配的一个互斥体可能用来这些进程的同步。
我们可以使用pthread_mutexattr_getpshared函数来查询一个pthread_mutexattr_t结构体来得到进程共享属性。我们可以用pthread_mutexattr_setpshared函数改变进程共享属性。
#include
int pthread_mutexattr_getpshared(const pthread_mutexattr_t *restrict attr, int *restrict pshared);
int pthread_mutexattr_setpshared(pthread_mutexattr_t *attr, int pshared);
两者成功返回0,失败返回错误号。
进程共享互斥体属性允许pthread库提供更高效的实现,当属性被设为PTHREAD_PROCESS_PRIVATE时,这是多线程应用的默认情况。然后pthread库可以在多进程之间共享互斥体的情况下限制更昂贵的实现。
类型互斥体属性控制了互斥体的特性。POSIX.1定义了四种类型。PTHREAD_MUTEX_NORMAL类型是不作任何错误检查或死锁检测的标准互斥体。PTHREAD_MUTEX_ERRORCHECK互斥体类型提供了错误检查。
PTHREAD_MUTEX_RECUSIVE互斥体类型允许相同的线程锁住它多次而不必事先解锁。一个递归互斥体维护一个锁计数并直到它被解锁次数与加锁次数相同才会被释放。
最终,PTHREAD_MUTEX_DEFAULT类型可以用来请求默认语义。实现可以自由地把它映射到其它某个类型。例如,在Linux,这个类型被映射被映射到普通互斥体类型。
四种类型的行为被展示在下表。“当不被拥有时解锁”列表示一个线程解锁一个被一个不同线程锁住的互斥体。“当无锁时解锁”列表示当一个线程解锁一个已经解锁的互斥体会发生什么,它通常是一个代码错误。
互斥体类型 | 未解锁时重新加锁? | 当不被拥有时解锁? | 当无锁时解锁? |
---|---|---|---|
PTHREAD_MUTEX_NORMAL | 死锁 | 无定义 | 无定义 |
PTHREAD_MUTEX_ERRORCHECK | 返回错误 | 返回错误 | 返回错误 |
PTHREAD_MUTEX_RECURSIVE | 允许 | 返回错误 | 返回错误 |
PTHREAD_MUTEX_DEFAULT | 无定义 | 无定义 | 无定义 |
我们可以用pthread_mutexattr_gettype来得到互斥体类型属性,pthread_mutexattr_settype来改变互斥体属性。
#include
int pthread_mutexattr_gettype(const pthread_mutexattr_t * restrict attr, int *restrict type);
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
两者成功返回0,失败返回错误号。
回想11.6节,一个互斥体被用来保护和一个条件变量相关的条件。在阻塞线程前,pthread_cond_wait和pthread_cond_timedwait函数释放了条件相关的互斥体。这允许其它线程申请互斥体,改变条件,释放互斥体,并发信号给条件变量。因为互斥体必须被握住来改变条件,所以使用一个递归互斥体不是好主意。如果一个递归互斥体被锁住多次并在pthread_cond_wait调用里使用,那么条件永远不能被满足,因为pthread_cond_wait完成的解锁不会释放这个互斥体。
在你需要把已有的单线程接口适配到多线程环境里,但因为兼容性限制又不能改变函数接口时,递归互斥体很有用。尽管如此,使用递归锁会很复杂,它们只在没有其它可能的解决方案时使用。
下面的例子演示了一个递归互斥体可能似乎解决一个并发问题的情况:假定func1和func2是库里已有的函数,它们接受结构体的地址作为参数,设它为x。func1(x)调用func2(x)。它们的接口不能改变,因为存在应用调用它们,而应用不能被改变。
为了保持接口不变,我们在其地址被作为参数传递的数据结构里内嵌一个互斥体,设它为x->lock。这只在我们为这个结构体提供一个分配器函数时才有可能,所以应用不知道它的尺寸(假定我们在为它加入一个互斥体时必须增加它的尺寸)。
如果我们最初定义这个结构体时预留了一些我们现在可以添加一个互斥体时的空间,这也是有可能的。不幸的是,多数程序员没有预测未来的技能,所以这不是一个普遍的实践。
如果func1和func2都必须操作这个结构体而它可能同时被多个线程访问,那么func1和func2必须在操作这个数据前锁住这个互斥体。如果func1必须调用func2,那么如果互斥体类型不是递归的话我们会死锁,因为x->lock在func1里已经锁住,func2里再次尝试锁住同一个互斥体。我们可以避免使用递归互斥体,如果我们在调用func2之前释放互斥体,并在func2返回后申请它,但是这打开了一个时间间隙,此时另一个线程可能可以得到互斥体的控制并在func1运行到一半时改变这个数据结构。这可能是不可接受的,取决于互斥体意图提供的保护。
下面是这种情况下递归互斥体的一个替代方式:我们不改变func1和func2的接口,也避免使用一个递归互斥体,通过提供func2的一个私有版本,称为func2_locked。为了调用func2_locked,我们必须握住其地址作为参数的结构体内部的互斥体(x->lock)。func2_locked包含func2函数体的复制,而fun2只是简单地申请互斥体,调用func2_locked,然后释放互斥体。而func1也直接调用func2_locked,这样就不必多次申请同一个锁。
如果我们不要保持库函数接口,我们可以给每个函数加上第二个参数来指定这个结构体是否被调用者锁住。尽管如此,通常如果可以的话最多都保持接口不变,而不是用实现方式的产物来污染它。
在简单情况下,提供函数的锁版本和无锁版本的策略通常都可行。在更复杂的情况,比如当库需要调用库外面的一个函数,这个函数然后可能调回到这个库里,那么我们需要依赖于递归锁。
下面的代码演示了另一个需要递归锁的情况。这里,我们有一个“计时”函数,允许我们安排另一个函数在将来某时运行。假定线程是不昂贵的资源,我们可以为每个待定定的计时创建一个线程。线程一直等到时间到达,然后它调用我们请求的函数。
我们使用12.3节的makethread函数来创建分离状态的线程。我们将这个函数在将来运行,而不想等待线程的完成。
我们可以调用sleep来等待计时过期,但是那只给了我们秒的粒度。如果我们想等待一些不是整数秒的时间,那么我们需要使用nanosleep,它提供了相似的功能。
尽管nanosleep只被要求实现在SUS的实时扩展里,但是本文所有的平台都支持它。
timeout的调用者需要握住一个互斥体把检查条件和安排retry函数作为一个原子操作。retry函数将尝试锁住相同的互斥体。除非这个互斥体是递归的,否则如果timeout直接调用retry会有死锁发生。
读写锁属性
读写锁也有属性,和互斥体相似。我们使用pthread_rwlockattr_init来初始化一个pthread_rwlockattr_t结构体和pthread_rwlockattr_destroy来反初始化这个结构体。
#include
int pthread_rwlockattr_init(pthread_rwlockattr_t *attr);
int pthread_rwlockattr_destroy(pthread_rwlockattr_t *attr);
两者成功返回0,失败返回错误号。
为读写锁支持的唯一的属性是进程共享属性。它和互斥体的进程共享属性一样。正如互斥体进程共享属性,一对函数被提供来得到和设置读写锁的进程共享属性。
#include
int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *restrict attr, int *restrict pshared);
int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *attr, int pshared);
两者成功返回0,失败返回错误号。
尽管POSIX只定义一个读写锁属性,实现可以定义补充的非标准的属性。
条件变量属性
条件变量也有属性。有一对函数用来初始化和反初始化它们,和互斥体与读写锁一样。
#include
int pthread_condattr_init(pthread_condattr_t *attr);
int pthread_condattr_destroy(pthread_condattr_t *attr);
两者成功返回0,失败返回错误号。
和其它同步原始对象一样,条件变量也支持进程共享属性。
#include
int pthread_condattr_getpshared(const pthread_condattr_t *restrict attr, int *restrict pshared);
int pthread_condattr_setpshared(pthread_condattr_t *attr, int pshared);
两者成功返回0, 失败返回错误号。
12.5 再入(Reentrancy)
我们在10.6节讨论过再入函数和信号处理机。线程和信号处理机一样,当考虑再入时。有了信号处理机和线程,多线程控制可能潜在地同时调用相同的函数。
如果一个函数可以同时被多个线程同时调用,那么我们说这个函数是线程安全(thread-safe)的。所有在SUS定义的函数都被保证为线程安全的,除了下表列出的。此外,ctermid和tmpnam函数不被保证为线程安全的,如果它们被传入空指针。相似地,wcrtomb和wcsrtombs也不保证为线程安全的,当它们的mbstate_t参数被传入空指针。
asctime | ecvt | gethostent | getutxline | putc_unlocked |
basename | encrypt | getlogin | gmtime | puchar_unlocked |
catgets | endgrent | getnetbyaddr | hcreate | putenv |
crypt | endpwent | getnetbyname | hdestroy | pututxline |
ctime | endutxent | getnetent | hsearch | rand |
dbm_clearerr | fcvt | getopt | inet_ntoa | readdir |
dbm_close | ftw | getprotobyname | l64a | setenv |
dbm_delete | gcvt | getprotobynumber | lgamma | setgrent |
dbm_error | getc_unlocked | getprotoent | lgammaf | setkey |
dbm_fetch | getchar_unlocked | getpwent | lgammal | setpwent |
dbm_firstkey | getdate | getpwnam | localeconv | setutxent |
dbm_nextkey | getenv | getpwuid | localtime | strerror |
dbm_open | getgrent | getservbyname | lrand48 | strtok |
dbm_store | getgrgid | getservbyport | mrand48 | ttyname |
dirname | getgrnam | getservent | ntfw | unsetenv |
dlerror | gethostbyaddr | getutxent | nl_langinfo | wcstombs |
drand48 | gethostbyname | getutxid | ptsname | wctomb |
支持线程安全函数的实现会在
当支持线程安全函数特性时,一个实现为一些不是线程安全的POISX.1函数提供一个替代的、线程安全的版本。下表列出了这些函数的线程安全版本。许多函数不是线程安全的,因为它们返回一个存储在静态内存缓冲的数据。通过改变它们的接口来需要调用者提供它自己的缓冲,它们被实现为线程安全的。
acstime_r | gmtime_r |
ctime_r | localtime_r |
getgrgid_r | rand_r |
getgrnam_r | readdir_r |
getlogin_r | strerror_r |
getpwnam_r | strtok_r |
getpwuid_r | ttyname_r |
如果函数关于多线程是可再入的,那么我们说它是线程安全的。然而,这并不是告诉我们这个函数关于信号是否是可再入的。我们说从异步信号处理机再入时是安全的的函数是异步信号安全的(async-signal safe)。我们在10.6节讨论再入函数时看到了异步信号安全函数。
除了上表列出的函数,POSIX.1提供了一种以线程安全方式管理FILE对象的方法。你可以使用flockfile和ftrylockfile来得到给定FILE对象相关的锁。这个锁是递归的:当你握住它时,你可以再次申请它,而不会造成死锁。尽管锁的精确的实现没有规定,但是所有操作FILE对象的标准I/O例程都需要表现得好像它们内部调用flockfile和funlockfile。
#include
int ftrylockfile(FILE *fp);
成功返回0,不能得到锁返回非0.
void flockfile(FILE *fp);
void funlockfile(FILE *fp);
尽管标准I/O例程从它们自己内部的数据结构来看,可能被实现为线程安全的,但是暴露这个锁给应用是仍然有用的。这允许应用组合多个标准I/O函数为一个原子序列。当然,当处理多个FILE对象时,你需要知道潜在的死锁并小心得排序你的锁。
如果标准I/O例程申请它们自己的锁,那么我们在执行一次一字符I/O时会进入严重的性能退化。这种情况下,我们为每个字符的读或写申请和释放锁。为了避免这个开销,基于字符的标准I/O例程的无锁版本可用。
#include
int getchar_unlocked(void);
int getc_unlocked(FILE *fp);
两者成功返回下个字符,文件尾或错误返回EOF。
int putchar_unlocked(int c);
int putc_unlocked(int c, FILE *fp);
两者成功返回c,错误返回EOF。
这四个函数不应该被调用,除非被flockfile或ftrylockfile调用和funlockfile调用包围。否则,不可预期的结果会发生(也就是说,这种类型的问题由多线程控制的未同步的数据访问导致)。
一旦你锁住FILE对象,你可以在释放这个锁之前多次调用这些函数。这把锁的开销平摊到读写数据的量上。
下面的例子展示了getenv(7.9节)的一个可能的实现。这个版本是不可再入的。如果两个线程同时调用它,那么它们将看到不一致的结果,因为返回的字符串被存储在单个静态缓冲里,它被所有调用getenv的线程共享。
我们可以用读写锁来允许多个并发调用getenv_r,但是加上的并发很可能不会提高我们程序的性能很多,由于两个原因。第一,环境列表通常不是很长,所以我们不必在浏览这个列表时不必握住这个互斥体太久。第二,getenv和putenv的调用是不频繁的,所以如果我们提高它们的性能,我们对程序的整体性能也不会有很大影响。
如果我们使getenv_r线程安全,那么这不表示它是关于信号处理机可再入的。如果我们使用一个非递归的互斥体,那么我们冒着一个线程如果从一个信号处理机里调用getenv_r会死锁它自己的风险。如果信号处理机在线程执行getenv_r时中断了它,我们将正锁着env_mutex,所以另一个锁住它的尝试会阻塞,导致线程死锁。因而,我们必须使用一个递归互斥体来防止其它线程在我们查看数据结构时改变它们,也防止从信号处理机而来的死锁。问题是pthread函数不被保证是异步信号安全的,所以我们不能使用它们来使另一个函数异步信号安全。
12.6 线程特定数据(Thread-Specific Data)
线程特定数据,也被称为线程私有数据,是一种存储和查找一个特定线程相关数据的机制。我们称这个数据为线程特定或线程私有的原因,是我们想每个线程访问它自己独立的数据拷贝,而不用担心和其它线程的访问的同步。
许多人在设计促进共享进程数据和属性的线程模型时碰到很多麻烦。所以为什么任何人在这个模型里都想要促进避免共享的接口呢?有两个原因。
首先,有时我们需要为每个线程维护数据。因为没有保证线程ID是小的连续的整型,所以我们不能简单地为线程数据分配一个数组,并使用线程ID作为索引。即使我们可以依赖于小的连续的线程ID,我们仍想要一些额外的保护以便一个线程不会和破坏另一个的数据。
第二个线程私有数据的原因是为了提供把基于进程的接口适配到一个多线程环境的一个机制。一个明显的例子是errno。回想1.7节errno的讨论。更老的接口(在线程的出现之前)定义errno作为在进程上下文里全局访问的一个整型。系统调用和库例程设置errno作为失败的一个副作用。为了使线程可以使用这些相同的系统调用和库例程,errno被重新定义为线程私有数据。因而,一个调用设置errno的函数的线程不会影响进程里其它线程的errno值。
回想进程里的所有线程访问进程的整个地址空间。除了使用寄存器,没有办法让一个线程阻止另一个访问它的数据。即使对于线程特定数据也是如此。尽管底下的实现不阻止访问,然而为管理线程特定数据而提供的函数促进了线程间的数据分离。
#include
int pthread_key_create(pthread_key_t *keyp, void (*destructor)(void *));
成功返回0,失败返回错误号。
创建的关键字被存储在keyp所指的内存位置。这个相同的关键字可以被进程里所有线程使用,但是每个线程将使用这个关键字关联于一个不同的线程特定数据地址。当创建字被创建时,每个线程的数据地址被设为空值。
除了创建一个关键字,pthread_key_create还把一个可选的析构函数关联于这个关键字。当线程退出时,如果数据地址被设置为非空值,析构函数被创建,这个数据地址被作为唯一的参数。如果destructor为空,那么没有析构函数被关联到这个关键字。当线程通过调用pthread_exit或return正常退出时,destructor被调用。但是如果线程调用exit、_exit或_Exit或abort,或其它异常退出,那么析构体不被调用。
线程通常使用malloc来为它们的线程特定数据分配内存。析构函数通常释放被分配的内存。如果线程退出时不释放内存,内存将被丢失:被进程泄露。
一个线程可以为线程特定数据分配多个关键字。每个关键字可以有一个与它关联的析构体。每个关键字可以有一个不同的析构函数,它们也可以全部使用相同的函数。每个操作系统实现可以为一个进程可以分配的关键字数设置一个限量(回想12.2节的PTHREAD_KEYS_MAX)。
当一个线程退出时,它的线程特定数据的析构体以一个实现定义的顺序被调用。析构函数可能调用另一个可能创建新的线程特定数据并把它关联到这个关键字的函数。在所有析构体被调用后,系统将检查任何非空线程特定数据是否与关键字相关联,如果有,再次调用析构体。这个过程会重复,直到线程的所有关键字都有空的线程特定数据值,或者最多尝试了PTHREAD_DESTRUCTOR_ITERATIONS(12.2节)次。
我们可以为所有线程打破关键字和线程特定数据值的关联,通过调用pthread_key_delete。
#include
int pthread_key_delete(pthread_key_t *key);
成功返回0,失败返回错误号。
注意调用pthread_key_delete将不会调用key相关的析构体。要释放任何与key的线程特定数据相关的内存,我们必须在应用里采取额外的步骤。
我们需要确保我们分配的一个关键字不会因为初始化期间的一个竞争而改变。像下方的代码可以导致两个线程都调用pthread_key_create:
void destructor(void *);
pthread_key_t key;
int init_done = 0;
int
thread_func(void *arg)
{
if (!init_done) {
init_done = 1;
err = pthread_key_create(&key, destructor);
}
...
}
取决于系统如果调度线程,一些线程可能看到一个关键字值,而另一个线程可能看到一个不同的值。解决这个竞争的方法是使用pthread_once。
#include
pthread_once_t initflag = PTHREAD_ONCE_INIT;
int pthread_once(pthread_once_t *initflag, void (*initfn)(void));
成功返回0,失败返回错误码。
initflag必须是一个非局部变量(也就是全局或静态的)并初始化为PTHREAD_ONCE_INIT。
如果每个线程都调用pthread_once,那么系统保证初始化例程initfn将只被调用一次,在第一次调用pthread_once时。没有竞争的创建一个关键字的恰当的方法如下:
void destructor(void *)
pthread_key_t key;
pthread_once_t init_done = PTHREAD_ONCE_INIT;
void
thread_init(void)
{
err = pthread_key_create(&key, destructor);
}
int
threadfunc(void *arg)
{
pthread_once(&init_done, thread_init);
...
}
一旦一个关键字被创建,我们就可以把线程特定数据关联到这个关键字,通过调用pthread_setspecific。我们可以用pthread_getspecific来得到线程特定数据的地址。
#include
void *pthread_getspecific(pthread_key_t key);
返回线程特定数据,或者如果没有值关联到这个关键字时返回NULL。
int pthread_setspecific(pthread_key_t key, const void *value);
成功返回0,失败返回错误号。
如果没有线程特定数据被关联到一个关键字,那么pthread_getspecific将返回一个空指针。我们可以使用它来确定是否需要调用pthread_setspecific。
在上一节,我们展示了getenv的一个猜想的实现。之后创建一个新的接口,但是以线程安全的方式提供了相同的功能。但是如果我们不想修改应用程序来使用新的接口会发生什么呢?在这种情况下,我们可以使用线程特定数据来为每个线程维护一个用来保存返回的字符串的数据缓冲的拷贝。由下面的代码展示。
注意尽管这个版本的getenv是线程安全的,但它不是异步信号安全的。即使我们让互斥体递归,我们也不能让它关于信号处理机可再入,因为它调用了malloc,它自身就不是异步信号安全的。
12.7 取消选项(Cancel Options)
没有包含在pthread_attr_t结构体里的两个线程属性是取消状态和取消类型。这些属性影响了一个线程响应pthread_cancel(11.5节)调用的行为。
响应状态属性可以是PTHREAD_CANCEL_ENABLE或PTHREAD_CANCEL_DISABLE。一个线程可以改变它的取消状态,通过调用pthread_setcancelsatate。
#include
int pthread_setcancelstate(int state, int *oldstate);
成功返回0,失败返回错误号。
在一个原子操作里,pthread_setcancelstate设置当前的取消状态为state,并存储之前的取消状态到oldstate所指的内存位置。
回想11.5节,一个pthread_cancel的调用不等待一个线程的终止。在默认情况下,线程将在取消请求发出后继续执行,直到这个线程到达取消点。一个取消点是线程检查它是否被取消并执行这个请求的位置。POSIX.1保证取消点将发生在当一个线程调用下表列出的任何函数时。
accept | mq_timedsend | putpmsg | sigsuspend |
aio_suspend | msgrcv | pwrite | sigtimedwait |
clock_nanosleep | msgsnd | read | sigwait |
close | msync | readv | sigwaitinfo |
connect | nanosleep | recv | sleep |
creat | open | recvfrom | system |
fcnt12 | pause | recvmsg | tcdrain |
fsync | poll | select | usleep |
getmsg | pread | sem_timedwait | wait |
getpmsg | pthread_cond_timedwait | sem_wait | waitid |
lockf | pthread_cond_wait | send | waitpid |
mq_receive | pthread_join | sendmsg | write |
mq_send | pthread_testcancel | sendto | writev |
mq_timedreceive | putmsg | sigpause |
除了上表列出的函数,POSIX.1还列出下表中的函数作为可选取消点。
catclose | ftell | getwc | printf |
catgets | ftello | getwchar | putc |
catopen | ftw | getwd | putc_unlocked |
closedir | fwprintf | glob | putchar |
closelog | fwrite | iconv_close | putchar_unlocked |
ctermid | fwscanf | iconv_open | puts |
dbm_close | getc | ioctl | pututxline |
dbm_delete | getc_unlocked | lseek | putwc |
dbm_fetch | getchar | mkstemp | putwchar |
dbm_nextkey | getchar_unlocked | nftw | readdir |
dbm_open | getcwd | opendir | readdir_r |
dbm_store | getdate | openlog | remove |
dlclose | getgrent | pclose | rename |
dlopen | getgrgid | perror | rewind |
endgrent | getgrgid_r | popen | rewinddir |
endhostent | getgrnam | posix_fadvise | scanf |
endnetent | getgrnam_r | posix_fallocate | seekdir |
endprotoent | gethostbyaddr | posix_madvise | semop |
endpwent | gethostbyname | posix_spawn | setgrent |
endservent | gethostent | posix_spawnp | sethostent |
endutxent | gethostname | posix_trace_clear | setnetent |
fclose | getlogin | posix_trace_close | setprotoent |
fcntl | getlogin_r | posix_trace_create | setpwent |
fflush | getnetbyaddr | posix_trace_create_withlog | setservent |
fgetc | getnetbyname | posix_trace_eventtypelist_getnext_id | setutxent |
fgetpos | getnetent | posix_trace_eventtypelist_rewind | strerror |
fgets | getprotobyname | posix_trace_flush | syslog |
fgetwc | getprotobynumber | posix_trace_get_attr | tmpfile |
fgetws | getprotoent | posix_trace_get_filter | tmpnam |
fopen | getpwent | posix_trace_get_status | ttyname |
fprintf | getpwnam | posix_trace_getnext_event | ttyname_r |
fputc | getpwnam_r | posix_trace_open | ungetc |
fputs | getpwuid | posix_trace_rewind | ungetwc |
fputwc | getpwuid_r | posix_trace_set_filter | unlink |
fputws | gets | posix_trace_shutdown | vfprintf |
fread | getservbyname | posix_trace_timedgetnext_event | vfwprintf |
freopen | getservbyport | posix_typed_mem_open | vprintf |
fscanf | getservent | pthread_rwlock_rdlock | vwprintf |
fseek | getutxent | pthread_rwlock_timedrdlock | wprintf |
fseeko | getutxid | pthread_rwlock_timedwrlock | wscanf |
fsetpos | getutxline | pthread_rwlock_wrlock |
如果你的应用在很长时间内没有调用上面两个表中的某个函数(比如受计算机限制),那么你可以调用pthread_testcancel来在你的程序里加入你自己的取消点。
#include
void pthread_testcancel(void);
当你调用pthread_testcancel时,如果一个取消请求已经待定,且取消没有被禁止,那么线程将被取消。当取消被禁止时,调用pthread_testcancel没有效果。
我们已经描述过的默认取消类型被称为延迟取消。在pthread_cancel调用后,真实的取消不会发生,直到线程碰到一个取消点。我们可以调用pthread_setcanceltype来改变取消类型。
#include
int pthread_setcanceltype(int type, int *oldtype);
成功返回0,失败返回错误号。
type参数可以是PTHREAD_CANCEL_DEFERRED或PTHREAD_CANCEL_ASYNCHRONOUS。pthread_setcanceltype函数设置取消类型为type并返回之前的类型到oldtype所指的整型。
异步取消和延迟取消区别在于线程可以随时被取消。线程不必击中一个取消点来被取消。
12.8 线程和信号(Threads and Signals)
处理信号甚至可以用一个基于进程的范型来完成。把线程引入到系统让事情变得更复杂。
每个线程有它自己的信号掩码,但是信号布署被进程里的所有线程共享。这意味着个体线程可以阻塞信号,但是当一个线程修改了一个给定信号的相关动作时,所有的线程都共享这个动作。因而,如果一个线程选择忽略一个给定信号,那么另一个线程可以撤消那个选择,通过恢复默认布署或为信号安装一个信号处理机。
信号被分发给进程里的单个线程。如果信号和一个硬件错误或过期的计时器相关,那么信号被发送给其动作导致这个事件的线程。另一方面,其它信号被分发给任意一个线程。
在10.12节,我们讨论了进程如何使用sigprocmask来阻塞信号的分发。sigprocmask的行为在多线程下是无定义的。线程必须使用pthread_sigmask来替代。
#include
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
成功返回0,失败返回错误号。
pthread_sigmask函数和sigprocmask相同,除了pthread_sigmask和线程工作,并在失败时返回一个错误码而不是设置errno并返回-1。
一个线程可以等待一个或多个信号的发生,通过调用sigwait。
#include
int sigwait(const sigset_t *restrict set, int *restrict signop);
成功返回0,失败返回错误号。
set参数指定了线程正在等待的信号集。在返回时,signop指向的整型将包含被分发的信号号。
如果set里指定的某个信号在sigwait被调用时正待定,那么sigwait会返回而不被阻塞。在返回前,sigwait从进程的待定信号集里删除这个信号。为了避免错误行为,一个线程必须在调用sigwait之前阻塞信号。sigwait函数将自动阻塞信号并等待某个被分发。在返回前,sigwait会恢复线程的信号掩码。如果信号在sigwait被调用时没有被阻塞,那么在它线程完成它的sigwait调用前,会有时间间隙,信号可以被分发到线程。
使用sigwait的好处是它能简化信号处理,通过允许我们用同步方式处理异步产生的信号。我们可以避免信号中断信号,通过把它们加到每个线程的信号掩码里。然后我们可以指定特定的线程来处理信号。这些被指定的线程可以进行函数调用,而不必担心哪些函数可以在一个信号处理机里安全地调用,因为它们从普通的线程上下文里被调用,而不是传统的中断一个普通线程执行的信号处理机。
如果多线程在sigwait调用时阻塞在同一个信号上,那么当信号被分发时只有一个线程从sigwait返回。如果信号被捕获(例如进程使用sigaction建立了一个信号处理机)而线程正在sigwait调用里等待相同的信号,那依赖于实现来决定如何分发信号。在这种情况下,信号可能允许sigwait返回或调用信号处理机,但不会同时。
为了发送信号给一个进程,我们调用kill(10.9)。为了发送一个信号给一个线程,我们调用pthread_kill。
#include
int pthread_kill(pthread_t thread, int signo);
成功返回0,失败返回错误码。
我们可以传递值为0的signo来检查线程的存在性。如果一个线程的默认动作是终止这个进程,那么发送信号给一个线程仍然会杀死整个进程。
注意闹钟计时器是一个进程资源,而所有线程共享同一个闹钟集。因此,进程里的多线程无法在不影响(或合作)另一个线程的情况下使用闹钟计时器。
回想第10.16节,我们等待一个信号处理机来设置一个表示主程序应该退出的标志。仅有的可以运行的线程控制是主线程和信号处理机,所以阻塞信号足以避免标志改变的丢失。有了线程,我们需要使用一个互斥体来保护这个标准,如下面的代码所示:
注意我们在主线程开头阻塞了SIGINT和SIGQUIT。当我们创建线程来处理信号时,线程继承了当前的信号掩码。因为sigwait将反阻塞信号,只有一个线程可以收到信号。这让我们在不用担心从这些信号中断的情况下编码我们的主线程。
运行结果为:
^C
interrupt
^C
interrupt
^\$
Linux实现线程为单独的进程,用clone共享资源。因为如此,Linux上的线程对待信号的行为和其它实现不同。在POSIX.1线程模型里,异步信号被发送给一个进程,然后进程里的某个线程被选来收到这个信号,基于当前哪个线程没有阻塞这个信号。在Linux上,一个异步信号被发送给一个特定的线程,而因为每个线程作为单独的进程执行,系统不能选择一个当前没有阻塞该信号的线程。结果是线程可能注意不到信号。因而,像上面的代码当信号由终端驱动产生时可以工作,它向进程组发送信号,但是当你尝试用kill向进程发送一个信号,在Linux上它不会如预期地工作。(我的ubuntu上的Linux3.0好像可以用kill发送信号。)
12.9 线程和fork
当一个线程调用fork时,整个进程地址空间被拷贝给子进程。回想8.3节的写时拷贝的讨论。子进程是一个和父进程完全不同的进程,而只要两者不对它们的内存内容进行修改,那么内存页的拷贝就可以在父进程和子进程之间共享。
通过继承地址空间的一份拷贝,子进程也从父进程继承了每个互斥体、读写锁和条件变量的状态。如果父进程由多个线程组成,那么子进程将需要清理锁状态,如果它不要在fork返回时立即调用exec。
在子进程里,只有一个线程存在。它是在父进程里调用fork的线程的一个拷贝。如果父进程里的线程握住了任何锁,在子进程这个锁也会被握住。问题是子进程并不包含握住这些锁的线程的拷贝,所以子进程没有办法知道哪些锁被握住并需要被解锁。
如果子进程在从fork函数返回后直接调用某个exec函数时可以避免这个问题。在这种情况下,老的地址空间被舍弃,所以锁状态无关紧要。然而,这不总是可能的,所以如果子进程需要继续运行,那么我们需要用另一种策略。
为了清理锁状态,我们可以建立分叉处理机,通过调用函数pthread_atfork。
#include
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
成功返回0,失败返回错误号。
有了pthread_atfork,我们可以安装最多三个函数来帮助清理锁。prepare分叉处理机在父进程里fork创建子进程之前被调用。这个分叉处理机的工作是申请所有由parent定义的锁。parent分叉处理机在fork创建子进程之后但在for返回前,在父进程上下文里被调用。这个分叉处理机的工作是解锁所有prepare分叉处理机申请到的锁。child分叉处理机在从fork返回前在子进程的上下文里被调用。像parent分叉处理机一样,child分叉处理机也必须释放所有prepare分叉处理机申请的锁。
注意锁没有被加锁一次而解锁两次,虽然它看起来是这样。当子进程地址空间被创建时,它得到父进程定义的所有锁的一份拷贝。因为prepare分叉处理机申请所有的锁,父进程里的内存和子进程里的内存以相同的内容启动。当父进程和子进程解锁这些锁的“拷贝”时,子进程的新内存被分配,而父进程的内存内容被拷贝到子进程的内存里(写时拷贝),所以我们进入一个看起来好像父进程锁住它的锁的全部拷贝而子进程锁住它的锁的全部拷贝的情况。父进程和子进程最后解锁存储在不同内存位置的复制的锁,好像以下的事件序列发生一样:
1、父进程申请它所有的锁;
2、子进程申请它所有的锁;
3、父进程释放它的锁;
4、子进程释放它的锁。
我们可以调用pthread_atfork多次来安装多个分叉处理机集。如果我们没有需要使用某个处理机,我们可以传递空指针给特定的处理机参数,而它会没有效果。当多个分叉处理机被使用时,处理机被调用的顺序依情况而定。parent和child分叉处理机以它们被注册的顺序调用,而prepare分叉处理机以注册的相反顺序被调用。这允许多个模块来注册它们自己的分叉处理机并仍然遵守锁层次。
例如,假设模块A调用模块B的函数而每个模块有它自己的锁集。如果锁层次是A在B之前,模块B必须在模块A之间安装它的分叉处理机。当父进程调用fork时,以下的步骤被执行,假定子进程在父进程之间运行:
1、模块A的prepare分叉处理机被调用以申请所有A模块的锁;
2、B模块的prepare分叉处理机被调用以申请所有B模块的锁;
3、一个子进程被创建;
4、B模块的child分叉处理机被调用以释放子进程里所有B模块的锁;
5、A模块的child分叉处理机被调用以释放子进程里所有A模块的锁;
6、fork函数返回到子进程;
7、B模块的parent分叉处理机被调用以释放父进程里所有B模块的锁;
8、A模块的parent分叉处理机被调用以释放父进程里所有A模块的锁;
9、fork函数返回到父进程。
如果分叉处理机服务于清理锁状态,那么什么清理条件变量的状态呢?在一些实现上,条件变量可能不需要任何清理。然而,一个使用锁作为条件变量实现的一部分的实现将需要清理。问题是没有接口允许我们这样做。如果锁被内嵌到条件变量数据结构里,那么我们不能在fork调用后使用条件变量,因为没有可移植的方法来清理它的状态。另一方面,如果一个实现使用一个全局锁来保护进程里的所有条件变量,那么实现本身可以在fork库例程里清理这个锁。然而应用程序不应该依赖于这样的实现细节。
下面的程序演示了pthread_atfork和分叉处理机的使用:
运行结果为:
$ ./a.out
thread started...
parent about to fork...
preparing locks...
parent unlocking locks...
parent returned from fork
child unlocking locks...
child returned from fork
12.10 线程和I/O
我们在3.11节介绍了pread和pwrite。这些函数有助于多线程环境,因为进程里的所有线程共享相同的文件描述符。
考虑两个线程同时读或写一个相同的文件。
线程A:
lseek(fd, 300, SEEK_SET);
read(fd, buf1, 100);
线程B:
lseek(fd, 700, SEEK_SET);
read(fd, buf2, 100);
如果线程A执行lseek然后线程B在线程A调用read前调用lseek,那么两个线程最终都读到相同的记录。很明显,这不是我们想要的。
为了解决这个问题,我们可以使用pthread来让偏移量的设置和数据的读成为一个原子操作。
线程A:pread(fd, buf1, 100, 300);
线程B:pread(fd, buf2, 100, 700);
使用pread,我们可以确保线程A读取偏移量300的数据而线程B读取偏移量为700的数据。我们可以使用pwrite来解决并发线程写相同文件的问题。
12.11 总结
线程提供了UNIX系统部分并发的一个替代模型。线程促进各个线程控制之间的共享,但是带来同步的问题。在本章,我们看到我们如果调整我们的线程和它们的同步原始类型。我们讨论了线程的再入。我们也看到线程如何和面向进程的系统调用交互。
13.1 引言
守护进程是活很长时间的进程。它们进程当系统启动是被启动,当系统关闭时终止。因为它们没有控制终端,我们说它们在后台运行。UNIX系统有许多守护神,执行日常的任务。
在本章,我们看到守护神的进程结构体,以及如果写一个守护神。因为一个守护进程没有控制终端,所以我们需要看到守护进程当出错时如何报告错误。
13.2 守护进程特性(Daemon Characteristics)
让我们看看一个普遍的系统守护进程和它们如何和进程组、控制终端、和会话(第9章)的概念关联,ps命令打印系统的各种进程的状态。有许多选项--参考你系统手册。我们将执行ps -axj,在基于BSD的系统下来看我们讨论所需的信息。-a选项显示被其它人拥有的进程,而-x显示没有控制终端的进程。-j选项显示工作相关的信息:会话ID,进程组ID,控制终端,和终端进程组ID。在基于系统V的系统下,类似的命令是ps -efjc。(为了提升安全性,一些UNIX系统不允许我们使用ps来看不属于我们的任何进程。)ps的输出如:
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
0 1 1 1 ? -1 Ss 0 0:00 /sbin/init
0 2 0 0 ? -1 S 0 0:00 [kthreadd]
2 3 0 0 ? -1 S 0 0:00 [ksoftirqd/0]
2 6 0 0 ? -1 S 0 0:00 [migration/0]
2 7 0 0 ? -1 S 0 0:00 [migration/1]
2 9 0 0 ? -1 S 0 0:00 [ksoftirqd/1]
2 11 0 0 ? -1 S< 0 0:00 [cpuset]
2 12 0 0 ? -1 S< 0 0:00 [khelper]
2 13 0 0 ? -1 S< 0 0:00 [netns]
2 15 0 0 ? -1 S 0 0:00 [sync_supers]
我们感兴趣的列为父进程ID、进程ID、进程组ID、会话ID,终端名终端组ID(与控制终端相关的前台进程组),用户ID,和命令字符串。
运行这个ps命令的系统(Linux)支持一个会话ID的符号,我们在9.5节和setsid函数一起提到过。会话ID只是简单地为会话领导的进程ID。然而,基于BSD的系统将会打印session结构体的地址对应于这个进程所属的进程组(9.11节)。
你看到的系统进程将取决于操作系统实现。任何父进程ID为0的进程通常是一个内核进程,作为系统启动过程的一部分。(一个例外是init,它是在系统启动时由内核启动的用户级命令。)内核进程是特殊的,通常存在于系统的整个生命周期。它们以超级用户权限运行,并没有控制终端也没有命令行。
进程1通常是init,如我们在8.2节描述的。它是一个守护进程,负责在其它东西之间启动特定运行等级的系统服务。这些服务通常在它们自己的守护进程的帮助下实现。
在Linux上,keventd守护进程为运行内核里的调度的函数提供进程上下文。kapmd守护进程提供了高级的电源管理特性的支持,在各种计算机系统上可用。kswapd守护进程也被称为页出(page out)守护进程。它通过不时地缓慢地把脏页写到磁盘里来支持虚拟内存子系统,所以页可以被回收。
Linux内核使用两个额外的守护进程来把缓存的数据冲洗到磁盘:bdflush和kupdated。bdflush守护进程把脏缓冲从缓冲缓存冲洗回磁盘,当可用内存到达一个低水位标记。kupdated守护进程在定期把脏页冲洗回磁盘来减少系统失败事件的数据丢失。
端口映射守护进程,portmap,提供了把RPC(Remote Procedure Call,远程过程调用)程序号映射到网络端口号。syslogd守护进程对任意程序可用,为一个操作记录系统消息。消息可能被打印到一个控制台设备或写到一个文件。(syslog,13.4节)
我们9.3节说过inet守护进程。它监听系统的网络接口,等待对各种网络服务器的请求。nfsd、lockd、和rpciod守护进程提供了Network File System(NFS)的支持。
cron守护进程(crond)在指定的日期和时间执行命令。许多系统管理任务被cron定期执行的程序处理。cupsd守护进程是一个打印假脱机程序(sppler);它处理系统上的打印请求。
注意多数守护进程用超级用户权限运行(用户ID为0)。没有一个守护进程有控制终端:终端名被设为一个问号,以及终端前台进程组为-1。内核守护进程不带控制终端被启动。用户级守护进程的控制终端的缺失,很可能是守护进程调用了setsid的结果。所有用户组守护进程都是进程组长和会话领导,也是它们进程组和会话里的唯一进程。最后,注意这些守护进程里多数的父进程是init进程。
13.3 编码规则(Coding Rules)
编码一个守护进程的一些规则避免不想要的交互发生。我们介绍这些规则,然后展示一个实现它们的函数,daemonize。
1、第一件要做的事是调用umask来设置文件模式创建掩码为0。继承而来的文件模式创建掩码可以被设置为拒绝某些权限。如果守护进程要创建文件,它可以想设置特定的权限。例如,如果它明确地用组读和组写来创建文件,那么一个关闭这些权限的文件模式创建掩码将会撤销它的努力。
2、调用fork并让父进程exit。这里做了几件事。首先,如果守护进程被作为一个简单的外壳命令启动,那么让它的父进程终止会让外壳认为命令完成了。第二,子进程继承了父进程的进程组ID但有一个新的进程ID,所以我们被保证子进程不是一个进程组长。这是接下来调用setsid的先决条件。
3、调用setsid来创建新的会话。9.5节列出的三个步骤会生。进程a、变为一个新会话的会话领导;b、变为新进程组的进程组长;c、没有控制终端。在基于系统V的系统下,一些人建议此时再次调用fork并让父进程终止。第二个子进程作为守护进程继续。这保证了守护进程不是一个会话领导,这样避免了在系统V规则下的控制终端的申请(9.6节)。替代地,为了避免申请一个控制终端,确保无论何时打开一个终端设备时都要指明O_NOCTTY。
4、改变当前工作目录为根目录。从父进程继承的当前工作目录可能在一个挂载的文件系统上。因为守护进程通过活到系统重启,所以如果守护进程保持在一个挂载的文件系统上,那么系统文件可以被卸载。做为另一种选择,一个守护进程可能改变当前工作目录到一些特定的位置,在那里它们将执行它们的工作。例如,行打印机假脱机程序守护进程经常改变它们的外部设备地址。
5、不需要的文件描述符应该被关闭。这阻止了守护进程握住任何可能从父进程(外壳或其它进程)继承的文件描述符。我们可以使用我们的open_max函数(2.5节)或getrlimit函数(7.11节)来决定最高的文件描述符并关闭所有不超过这个值的描述符。
6、一些守护进程打开文件描述符0、1、2为/dev/null,以便任何尝试从标准输入读或向标准输出写的库例程都会没有效果。因为守护进程和一个终端设备无关联,所以没有地方可以显示输出;也没有地方从一个交互的用户那接收输入。即使守护进程从一个交互的会话启动,守护进程也在后台运行,而登录会话可以终止而不影响这个守护进程。如果其他用户在相同的终端设备上登录,那么我们不会想让守护进程的输出显示在终端上,而用户不会期望他们的输入被守护进程读。
下面展示了一个可以在一个想把自己初始化为一个守护进程的程序里调用的函数。
13.4 错误记录(Error Logging)
守护进程的一个问题是如何处理错误消息。它不能简单地向标准错误输出,因为它没有一个控制终端。我们不想所有的守护进程写到控制台设备,因为在许多工作站上,控制台设备运行一个窗口系统。我们也不想每个进程把它自己的错误写入分开的文件里。对任何管理系统的人来说,追踪哪个守护进程写到哪个记录文件并在日常基础上检查这些文件,是件头疼的事。我们需要一个集中的守护进程错误记录的设施。
BSD的syslog设施在Berkeley被开发,并广泛应用在4.2BSD里。多数继承自BSD的系统都支持syslog。
直到SVR4,系统V还没有一个集中的守护进程日志设施。
syslog函数作为一个XSI扩展被SUS包含。
BSD的syslog设施自从4.2BSD被广泛应用。多数守护进程使用这个设施。下图演示了它的结构:
有三种方法来产生记录消息:
1、内核例程可以调用log函数。这些消息可以被任何open或read设备/dev/klog的用户进程读。我们不会再描述这个函数,因为我们对写内核例程不感兴趣。
2、多数用户进程(守护进程)调用syslog函数来产生日志消息。我们稍后描述它的调用序列。这导致消息被发送给UNIX域的数据报套接字/dev/log。
3、在这个主机或通过TCP/IP网络连到这个主机的其它主机上,一个用户进程可以向UDP端口514发送日志消息。注意syslog函数从不产生这些UDP数据报:它们需要产生记录消息的进程显式地网络编程。
通常,syslogd守护进程读取这三种形式的日志消息。在启动时,守护进程读取一个配置文件,通常是/etc/syslog.conf,它决定哪些类型的消息会被发送。例如,紧急消息可以被发送给系统管理员(如果登录的话)并在控制台打印消息,而警告可能被记录到一个文件。
我们到这个设置的接口是通过syslog函数。
#include
void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
int setlogmask(int maskpri);
返回之前日志优先级掩码值。
调用openlog是可选的。如果它不被调用,syslog第一次调用时,openlog会自动被调用。调用closelog也是可选的--它只关闭用来与syslogd守护进程通信的文件描述符。
调用openlog让我们指明一个被加入到每个日志消息的ident。这通常是程序名(cron、inetd等)。option参数是指定各种选项的一个位掩码。下表描述了可用的选项,包括XSI列里的着重号,如果选项包含在SUS里的openlog定义里。
option | XSI | 描述 |
---|---|---|
LOG_CONS | * | 如果日志消息不能通过UNIX域数据报发送给syslogd,那么消息被写到控制台。 |
LOG_NDELAY | * | 立即打开到syslogd守护进程的UNIX域数据报套接字;不要等待到第一个消息被记录。通常,套接字直到第一个消息被记录时才被打开。 |
LOG_NOWAIT | * | 不要等待可能在记录消息的进程里创建的子进程。这避免了和捕获SIGCHLD的应用的冲突,因为应用可能在syslog调用wait时已经得到子进程的状态了。 |
LOG_ODELAY | * | 延迟到syslogd守护进程的连接,直到第一个消息被记录。 |
LOG_PERROR | 除了发送给syslogd之外还向标准错误写日志消息。(Solaris上不可用。) | |
LOG_PID | * | 在每个消息里记录进程ID。这是为了让fork一个子进程来处理不同请求的守护进程(和从不调用fork的守护进程相反,比如syslogd)。 |
openlog的facility参数从下表中选取。注意SUS只定义了在给定平台上常用的facility代码的子集。facility参数的原因是让配置文件指明从不同设施来的消息用不同的方式处理。如果我们不调用openlog,或我们用0的facility调用它,那么我们仍可以指定设施,作为syslog的priority参数的一部分。
facility | XSI | 描述 |
---|---|---|
LOG_AUTH | 身份识别程序:login、su、getty…… | |
LOG_AUTHPRIV | 和LOG_AUTH相同,但记录到严格权限的文件 | |
LOG_CRON | cron和at | |
LOG_DAEMON | 系统守护进程:inetd、routed、…… | |
LOG_FTP | FTP守护进程(ftpd) | |
LOG_KERN | 内核产生的消息 | |
LOG_LOCAL0 | * | 为本地使用保留 |
LOG_LOCAL1 | * | 为本地使用保留 |
LOG_LOCAL2 | * | 为本地使用保留 |
LOG_LOCAL3 | * | 为本地使用保留 |
LOG_LOCAL4 | * | 为本地使用保留 |
LOG_LOCAL5 | * | 为本地使用保留 |
LOG_LOCAL6 | * | 为本地使用保留 |
LOG_LOCAL7 | * | 为本地使用保留 |
LOG_LPR | 行打印机系统:lpd、lpc、…… | |
LOG_MAIL | 邮件系统 | |
LOG_NEWS | Usenet网络新闻系统 | |
LOG_SYSLOG | syslogd守护进程本身 | |
LOG_USER | * | 用户进程来的消息(默认) |
LOG_UUCP | UUCP系统 |
等级 | 描述 |
---|---|
LOG_EMERG | 紧急(系统不稳定)(最高优先级) |
LOG_ALERT | 情况必须立即修复 |
LOG_CRIT | 危急情况(例如硬件设备错误) |
LOG_ERR | 错误情况 |
LOG_WARNING | 警告情况 |
LOG_NOTICE | 普通,但重要的情况 |
LOG_INFO | 信息消息 |
LOG_DEBUG | 调试消息(最低优先级) |
format参数和任何剩余的参数被传递给vspring函数来格式化。在格式里的两个字符%m的任何出现将被对应于errno值的错误消息字符串(strerror)代替。
setlogmask函数可以用来为进程设置优先级掩码。这个函数返回前一个掩码。当记录优先级掩码被设置时,消息不会被记录,除非它们的优先级在掩码里被设置。注意设置记录优先级掩码为0的尝试将没有效果。
logger程序被许多系统提供,作为向syslog设施发送记录消息的一种方法。一些实现允许这个程序的可选参数,可指定facility、level和ident,尽管SUS没有定义这些选项。logger命令是为了需要产生记录消息的外壳脚本的非交互式运行。
在一个(假设的)行打印机假脱机系统守护进程里,你可以碰到代码:
openlog("lpd", LOG_PID, LOG_LPR);
syslog(LOG_ERR, "open error for %s: %m", filename);
第一个调用设置了ident字符串为程序名,指定进程ID应该总是被打印,并设置默认的facility为行打印系统。syslog的调用指明一个错误情况和一个消息字符串。如果我们没有调用openlog,那么第二个调用可能是:
syslog(LOG_ERR | LOG_LPR, "open error for %s: %m", filename);
这里,我们指定priority参数为等级和设施的联合。
除了syslog,许多平台提供了一个处理可变参数列表的变体。
#include
#include
void vsyslog(int priority, const char *format, va_list arg);
本文的四个平台都提供了vsyslog,但它没有包含在SUS里。
多数syslogd实现在短期把消息排队。如果一个复制的消息在这段时间内到达,那么syslog守护进程将不会把它写入到记录里。相反,守护进程会写一个类似于“上次消息重复N次”的消息。
(syslog写的消息可以在/var/log/syslog里找到。)
13.5 单实例守护进程(Single-Instance Daemons)
一些守护进程被实现为以便某一时刻为了恰当的操作只有它的单个拷贝运行。例如,守护进程可能需要对一个设备的排斥的访问。在cron守护进程的情况里,如果多个进程同时运行,那么每个拷贝可能会尝试启动单个调度的操作,导致重复的操作和很有可能的错误。
如果守护进程需要访问一个设备,设备驱动将有时阻止/dev里的对应设备的多个打开操作。这限制我们只能同时运行一个守护进程拷贝。尽管如此,如果没有这样的设备可用,我们需要自己做这个工作。
文件锁和记录锁机制提供了保证只有一个守护进程拷贝运行的基础。(我们14.3节讨论文件和记录锁。)如果每个守护进程创建一个文件并在整个文件上放置一个写锁,只有一个这样的写锁将被允许创建。后续的创建写锁的尝试将会失败,从而向守护进程的后续拷贝指明已经有另一个实例在运行。
文件和记录锁提供了一个便利的互斥机制。如果守护进程得到一个在整个文件上的写锁,那么守护进程退出时锁会自动删除。这简化了恢复,移除了我们在前一个守护进程里清理的需要。
下面代码的函数演示了文件和记录锁的用法来确保只有一个守护进程的拷贝正在运行。
我们需要裁切这个文件,因为守护进程的前一个实例可能已经有一个比我们的更大的进程ID,也就是更长的字符串。例如,如果前一个实现的进程ID为12345,而新的实例是9999,那么当我们把进程ID写到文件时,我们将在文件里留下99995。裁切这个文件避免前一个守护进程的数据出现,好像应用到当前守护进程一下。
13.6 守护进程公约(Daemon Conventions)
有个普遍的公约被UNIX系统里的守护进程遵守。
1、如果守护进程使用一个锁文件,那么文件通常存储在/var/run下。然而,注意,守护进程可能需要超级用户权限在这里创建一个文件。文件名通常是name.pid,这里name是守护进程或服务的名字。例如,cron守护进程的锁文件是/var/run/crond.pid。
2、如果守护进程支持配置选项,那么它们通常被存储在/etc里。配置文件命名为name.conf,这里name是守护进程或服务的名字。例如,syslogd守护进程的配置为/etc/syslog.conf。
3、守护进程可以通过命令行启动,但是它们通常从系统初始化脚本(/etc/rc*或/etc/init.d/*)来启动。如果守护进程应该在它退出时自动重启,那么我们可以安排init来重启它,如果我们为它在/etc/inittab里包含一个respwan项。
4、如果守护进程有一个配置文件,那么守护进程在启动时读它,但是通常不会再次看它。如果管理员改变了配置文件,那么守护进程需要停止并重启来使配置改变生效。为了避免这个,有些守护进程将捕获SIGHUP并在收到这个信号时重新读取它们的配置文件。因为它们不和终端关联,且不是无控制终端的会话领导,就是孤立线程组的成员,所以守护进程没有理由期望收到SIGHUP。因此,它们可以安全地重用它。
13.7 客户端服务器模型(Client-Server Model)
守护进程的一个普遍用处是作为一个服务器进程。事实上,早先我们称syslogd进程为一个服务器,它让用户进程(客户端)使用UNIX域数据报套接字发送消息给它。
一般说来,一个服务器是等待一个客户端联系它,请求某种类型的服务的一个进程。syslogd服务器提供的服务是记录一个错误消息。
在13.4节里的图里,客户端和服务器端的通信是单向的。客户端向服务器发送服务请求,服务器不向客户端发送回任何东西。在后面各章里,我们将看到许多在客户端和服务器端之间的双向通信。客户端向服务器发送一个请求,而服务器返回一个回复给客户端。
13.8 总结
守护进程在多数UNIX系统上一直运行。初始化我们自己的进程来作为一个守护进程运行需要一些小心和对第9章描述的进程关系的了解。在本单,我们开发了一个可能被一个守护进程调用来正确初始化她自己的函数。
我们也讨论了一个守护进程可以记录错误信息的方法,因为一个守护进程通常没有一个控制终端。我们讨论了守护进程在多数UNIX系统上遵守的公约,并展示了如何实现一些这样的公约的例子。
14.1 引言
本章覆盖了许多我们在术语“高级I/O”下综合的主题和函数:非阻塞I/O、记录锁、系统V STREAMS、I/O复用(select和poll函数)、readv和writev函数,和内存映射I/O(mmap)。我们需要在15、17章和后面章节的许多例子里讨论进程间通信前覆盖这些主题。
14.2 非阻塞I/O(Noblocking I/O)
10.5节里,我们说系统调用分为两种:“慢”的和其它的。慢系统调用是那些可以永远阻塞的,包括:
1、如果对于特定文件类型数据没有出现,读可以永远阻塞调用者(管道、终端设备、网络设备);
2、如果数据没有被这些相同的文件类型立即接受,那么写可以就远阻塞调用者(管道、网络流控制没有空间,等);
3、打开被阻塞,起不到特定文件上的一些条件发生(比如一个等待附加猫响应回答的终端设备的打开,或当没有其它进程打开FIFO来读时一个FIFO的只写的打开;
4、启用强制记录锁的文件的读写;
5、特定ioctl操作;
6、一些进程间通信函数(15章)。
我们也说过和磁盘I/O相关的系统调用并不认为是慢的,尽管读写一个磁盘文件可能暂时阻塞调用者。
非阻塞I/O让我们执行一个I/O操作,比如open、read或write,并不让它永远阻塞。如果操作不能完成,那么调用立即返回,随着一个表示操作可能已经阻塞的错误。
有两种方法来为给定的文件描述符指明非阻塞I/O。
1、如果我们调用open来得到描述符,我们可以指定O_NONBLOCK标志(3.3节);
2、对于一个已经打开的描述符,我们调用fctnl来打开O_NONBLOCK文件状态标志(3.14节)。3.14节展示了一个我们能调用来为一个描述符打开任意文件状态的函数。
系统V的早期版本使用标志O_NDELAY来指定非阻塞模式。这些系统V版本从read函数返回一个0的值,如果没有任何数据能读。因为这个0的返回值和通常UNIX系统的0表示文件末尾的约定有重叠,所以POSIX.1选择用一个不同名字和相同语义的非阻塞标志。事实上,在这些系统V的早期版本,当我们从read得到一个0的返回值时,我们不知道这个调用已经阻塞了还是碰到文件尾了。我们将看到POSIX.1要求read返回-1,并设置errno为EAGAIN,如果从一个非阻塞描述符读不出数据。一些从系统V继承的平台同时支持O_NDELAY和POSIX.1的O_NONBLOCK,但是在本文, 我们将只使用POSIX.1的特性。更老的O_NDELAY是为了向后兼容,而且不该在新应用里使用。
4.3BSD为fcntl提供了FNDELAY标志,它的语义稍微有些不同。不同于只影响描述符的文件状态,这个终端设备或套接字的标志同样变为非阻塞的,影响终端或套接字的所有用户,而不仅仅是共享相同文件表项的用户(4.3BSD非阻塞I/O只工作在终端和套接字)。还有,4.3BSD返回EWOURLDBLOCK,如果一个在非阻塞描述符上的操作无法不阻塞而完成。今天,基于BSD的系统提供了POSIX.1 O_NONBLOCK标志并定义EWOULDBLOCK为和EAGAIN相同。这些系统提供和其它POSIX兼容系统一样的非阻塞语义:文件状态的改变影响同一个文件表项的所有用户,但是和通过其它文件表项对相同设备的访问无关。
让我们看一下非阻塞I/O的例子。下面的代码从标准输入并尝试向标准输出写入最多500000字节。标准输出首先被设置为非阻塞的。输出是一个循环里,每个write的结果都被打印到标准错误上。函数clr_fl和我们在3.14节展示的set_fl函数相似。这个新的函数简单地清除一个或多个标志位。
在这个系统上,11的errno是EAGAIN。数据被终端驱动接受的量根据系统不同而不同。这个结果也根据你如何登录到系统有所区别:在系统控制台上,在硬连线的终端上,还是使用一个伪终端的网络连接上。如果你在你的终端运行一个窗口系统,那么你也正在使用一个伪终端设备。
在这个例子里,程序执行上上千次的write调用,尽管只需要10到20次来输出数据。其它的只是返回错误。这种类型的循环,称为轮询(polling),在多用户系统上是CPU时间的浪费。在14.5节,我们将看到非阻塞描述符的I/O复用是更有效的方式。
有时,我们可以使用多线程(11章)设计我们的应用来避免使用非阻塞I/O。我们可以允许单个线程阻塞在I/O调用里,如果我们可以在其它线程里继续运行。这有时会简化我们的程序,如我们将在21章看到的;然而,有时,同步开的开销可能比不使用线程加入更多的复杂性。
14.3 记录锁(Record Locking)
当两个人同时编辑同一个文件会发生什么呢?在多数UNIX系统里,文件的最终状态对应于最后写这个文件的进程。然而,在一些应用里,比如一个数据库系统,一个进程需要明确单独地写一个文件。为了给进程提供这样的能力,商业UNIX系统提供了记录锁。(在20章,我们使用记录锁开发一个数据库库)。
记录锁是通常描述一个正在读或写一个文件的某块区域的进程能够阻止其它进程修改这个文件的这块区域的术语。在UNIX系统下,形容词“记录”是一个误用,因为UNIX内核不了解文件里的记录。一个更好的术语是字符范围锁,因为它是被锁文件里的一个范围(可能是整个文件)。
历史
早期UNIX系统的一个批评是它们不能用来运行数据库系统,因为没有锁住文件的部分的功能。由于UNIX找到了进程商业计算环境的方法,各种组加入了记录锁的支持(当然,有区别地)。
早期Berkeley版本只支持flock函数。这个函数锁住整个文件,而不是一个文件的区域。
记录锁通过fcntl函数加入到系统V的第3个版本里。lockf函数基于这个被建立,支持了简化的接口。这些函数允许调用者锁住一个文件里的任意字节范围,从整个文件到文件里的单个字节。
POSIX.1选择了标准化fcntl的方法。下表展示了各种系统提供记录锁的形式。注意SUS在XSI扩展里包含了lockf。
系统 | 建议的 | 强制的 | fcntl | lockf | flock |
---|---|---|---|---|---|
SUS | * | * | XSI | ||
FreeBSD 5.2.1 | * | * | * | * | |
Linux 2.4.22 | * | * | * | * | * |
Mac OS X 10.3 | * | * | * | * | |
Solaris 9 | * | * | * | * | * |
记录锁最早由John Bass在1980加入到版本7。进入内核的系统调用是一个名为locking的函数。这个函数提供了强制记录锁并传播到许多系统III的版本。Xenis系统选择了这个函数,一些基于Intel的系统V的后代,比如OpenServer 5,仍然在Xnenix兼容的库里支持它。
fcntl记录锁
让我们重复下3.14节的fcntl函数的原型。
#include
int fcntl(int filedes, int cmd, ... /* struct flock *flockptr */);
如果成功根据cmd返回,错误返回-1。
对于记录锁,cmd是F_GETTLK、F_SETLK或F_SETLKW。第三个参数(我们称之为flockptr)是一个flock结构体指针。
struct flock {
short l_type; /* F_RDLCK, F_WRLCK, or F_UNLCK */
off_t l_start; /* offset in bytes, relative to l_whence */
short l_whence; /* SEEK_SET, SEEK_CUR, or SEEK_END */
off_t l_len; /* length, in bytes; 0 means lock to EOF */
pid_t l_pid; /* returned with F_GETLK */
};
这个结构体描述了:
1、渴望的锁的类型:F_RDLCK(一个共享读锁)、F_WRLCK(一个互斥写锁)、或F_UNLCK(解锁一个区域);
2、被锁或解锁的区域的开始字节偏移量(l_start和l_whence);
3、区域的字节尺寸(l_len);
4、可以阻塞当前进程的握住锁的进程ID(l_pid)(只由F_GETTLK返回)。
关于加锁和解锁的区域的指定有许多规则:
1、指定区域开始偏移量的两个元素和lseek函数(3.6节)的最后两个参数相似。事实上,l_whence成员被指定为SEEK_SET、SEEK_CUR或SEEK_END。
2、锁可以在当前的末尾之后开始和扩展,但是不能在文件开头之前开始或扩展。
3、如果l_len为0,它表示锁扩展到文件的最大偏移量。这允许我们锁住文件里从任何地方开始的区域,通过并包含添加到文件的任何数据(我们不必尝试猜测多少字节可能被添加到文件里)。
4、为了锁住整个文件,我们设置l_start和l_whence来指到文件的开头和指定一个0的长度(l_len)。(有几种方法来指定文件的开头,但是多数应用把l_start指定为0而l_whence作为SEEK_SET。)
我们提到两种类型的锁:一个共享读锁(F_DLCK的l_type)和一个排斥写锁(F_WRLCK)。基本的规则是任意数量的进程可以在一个给定字节上有一个共享读锁,但是只有一个进程在一个给定的字节上有一个排斥写锁。更甚,如果在一个字节上有一个或多个读锁,那么在这个字节上不能有任何写锁;如果在一个字节上有一个排斥写写,那么在这个字节上不能有任何写锁。
这个兼容性规则应用到由不同进程发出的锁请求,而不是单个进程发出的多个锁请求。如果一个进程已经在文件的一个范围有有了已有的锁,那么同一个进程的后续在相同区域放置一个锁的尝试会用新的锁代替已有的那个。因而,如果一个进程有一个文件的字节16-32上的写锁,然后在字节16-32上放置一个读锁,请求将会成功(假设我们没有其它进程尝试尝试锁住文件相同区域的竞争),而写锁会被一个读锁代替。
为了得到一个读锁,文件描述符必须为读打开;为了得到一个写锁,文件必须为写打开。
我们现在可以描述fcntl函数的三个命令。
F_GETLK:决定flockptr描述的锁是否被其它锁阻塞。如果一个会阻止我们的被创建的锁存在,在已有锁上的信息会覆盖flockptr指向的信息。如果没有阻止我们的被创建的锁存在,那么flockptr指向的结构体保持不变,除了l_type成员,它被设为F_UNLCK。
F_SETLK:设置flockptr描述的锁。如果我们尝试得到一个读锁(F_RDLCK的l_type)或一个写锁(F_WRLCK的l_type),而兼容性规则阻止系统给我们这个锁,fcntl立即返回,errno被设为EACCES或EAGAIN。尽管POSIX允许一个实现返回两个错误码中的一个,本文所有四个实现在锁请求不被满足时返回EGAIN。这个命令也用来清除由flockptr描述的锁(F_UNLCK的l_type)。
F_SETLKW:这个命令是F_SETLK的一个阻塞版本。(命令名里的W意思是等待。)如果请求的读锁或写锁不能得到,因为另一个进程正在锁住请求区域的部分,那么调用进程被催眠。进程在当锁变为可用时或当被一个信号中断时会醒过来。
注意用F_GETLK测试一个锁和尝试用F_SETLK或F_SETLKW得到那个锁不是一个原子操作。我们没有保证,在两个fcntl调用之间,一些别的进程不会进入并获得相同的锁。如果我们不想在等待一个锁可用时阻塞,那么我们必须处理从F_SETLK返回的可能错误。
注意POSIX.1不规定当一个进程写锁住一个文件的一个区域时,第二个进程在尝试得到相同区域的写锁时阻塞,第三个进程然后试图得到这个区域的另一个写锁时会发生什么。如果第三个进程被允许在这个区域放置一个读锁,只因为区域已经被写锁,那么实现可能会由于让写锁待定从而饿死进程。这意味着当在相同区域的更多的读锁请求到达时,有待定写锁的进程必须等待的时间会变长。如果读锁请求到达地足够迅速,在到达速度上没有消停,那么写者可能会等待很长的时间。
当设置或释放文件上的一个锁时,系统根据请求可以结合或切割邻接的区域。例如,如果我们锁住100-199字节,然后解锁150,内核会维护100-149和151-199上的锁。
如果我们锁住150,系统会把相邻的锁住的区域合并为100-199的单个区域。
为了让我们不必每次分配一个flock结构体并填充它的所有元素,下面代码里的lock_reg函数处理了所有这些细节。
下面的代码定义了lock_test函数,我们将使用它测试一个锁。
当两个进程都在等待对方锁住的的资源时,死锁会发生。如果一个控制了一个被锁资源并在尝试锁住另一个进程的所控制的一个资源时被催眠,会有潜在的死锁。
下面的代码展示了死锁的一个例子。
当一个死锁被察觉时,内核必须选择一个进程来接收返回的错误。在这个例子,子进程被选择了,但是这是一个实现细节。在一些系统上,子进程总是收到错误。在其它系统上,父进程总是收到错误。在一些系统上,你可能甚至看到错误在多次锁的尝试中在子进程和父进程都会出现。
隐含的继承和锁的释放
三条规则管理自动继承和记录锁的释放。
1、锁被关联到一个进程和一个文件。这有两个实现。第一个很明显:当一个进程终止时,它所有的锁都被释放。第二个很不明显:每当一个描述符被关闭时,在那个进程里的被那个描述符引用的文件上的锁都会被释放。这意味着如果我们:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);
那么在close(fd2)之后,在fd1上获得的锁会被释放。如果我们用open来代替dup会发生相同的事情,如:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = open(pathname, ...);
close(fd2);
在另一个描述符上打开相同的文件。
2、锁决不会通过fork被子进程继承。这意味着如果一个进程获得了一个锁然后调用fork,那么子进程被认为是另一个进程,在考虑由父进程得到的锁的时候。子进程必须调用fcntl来获得它在通过fork继承的任何描述符上的自己的锁。这是合理的,因为锁是用来阻止多个进程同时写相同的文件。如果子进程通过一个fork继承了锁,那么父子都可以同时向相同文件写。
3、锁被一个通过exec产生的新程序继承。然而,注意如果close-on-exec标志被设置到一个文件描述符时,当描述符的关闭作为exec的一部分时,所有底下文件的锁都被释放。
FreeBSD 实现
让我们简明看下FreeBSD实现使用的数据结构。这应用帮助规则1的验证,锁被关联到一个进程和一个文件。
考虑一个进程执行了下面的语句(忽略错误返回)。
fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1); /* parent write locks byte 0 */
if ((pid = fork()) > 0) { /* parent */
fd2 = dup(fd1);
fd3 = open(pathname, ...);
} else if (pid == 0) {
read_lock(fd1, 1, SEEK_SET, 1) /* child read locks byte 1 */
}
pause();
下图显示了在父子都暂停后导致的数据结构:
我们之前已经展示过open、fork和dup会导致的数据结构。这里新的是lockf结构体,从i-node结构体链到一起。注意每个lockf结构体都为一个给定进程描述了一个锁住的区域(由offset和length定义)。我们展示了这些结构体的两个:一个是父进程的write_lock的调用,另一个是子进程的read_lcok的调用。每个结构体都包含了对应的进程ID。
在父进程里,关闭fd1、fd2或fd3中的任何一个都会导致父进程的锁被释放。当这些文件描述符中的任一个被关闭时,内核遍历对应的i-node的锁的链表,并释放由调用进程握住的锁。内核不知道(也不关心)这三个描述符里哪个被父进程用来获得这个锁。
在13.5节的already_running的代码里,我们展示了守护进程如何使用一个文件的锁来确保只有一个守护进程的拷贝正在运行。下面的代码展示了守护进程用来在一个文件上放置一个写锁的lockfile函数的实现。
文件末尾的锁
当锁和解锁文件尾时要小心。多数实现把SEEK_CUR或SEEK_END的l_whence值转换为一个绝对文件偏移量,使用l_start和文件当前位置或当前长度。然而,我们经常需要指定一个相对于当前位置或当前长度的锁,因为我们不能调用lseek来得到当前文件的偏移量,因为我们还没有这个文件的锁。(其它进程有机会可能在lseek调用和这个锁调用之间改变文件的长度。)
考虑下面的的步骤:
write_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);
代码的结果可能不是你期望的。它从当前文件尾得到一个写锁,包括你可能添加到文件的任何未来的数据。假定当我们执行第一个write时我们在文件末尾,那将把文件扩展一个字节,然后那个字节将被锁住。接下来的解锁会删除添加数据到文件的将来的写,但是它在文件的最后一个字节上保留了一个锁。当第二个写发生时,文件末尾被扩展了一个字节,但是这个字节没有被锁。
当一个文件的某个部分被锁住时,内核把指定的偏移量转换为一个绝对的文件偏移量。除了指定一个绝对的文件偏移量(SEEK_SET),fcntl允许我们指定这个偏移量相对于文件的一个点:当前(SEEK_CUR)或文件末尾(SEEK_END)。内核需要记住当前文件偏移量或文件末尾无关的锁,因为当前偏移量和文件末尾可能会改变,而这些属性的改变不应该影响已有锁的状态。
如果我们想要删除覆盖我们第一次write里写的字节的锁,那么我们可以指定长度为-1。负长度的值表示指定偏移量之前的字节。
建议锁和强制锁(Advisory versus Mandatory Locking)
考虑一个数据库访问例程库。如果库里所有的函数都以统一 方式处理记录锁,那么我们说使用这些函数来访问数据库的任何进程集是协同操作进程。这些数据库访问函数可以使用建议锁,如果它们只被用来访问数据库。但是建议锁不阻止其它一些有数据库写权限的进程来写入任何它想写到数据库文件的东西。这个无赖进程是一个不合作的进程,因为它不使用接受的方法(数据库函数库)来访问数据库。
强制锁导致内核检查每个open、read和write来验证调用进程没有违反正被访问的文件上的锁。强制锁有时被称为强制模式(enforcement-mode)锁。
我们在前面的表里看到Linux 2.4.22和Solaris 9提供强制记录锁,但是FreeBSD 5.2.1和Mac OS X 10.3不是。强制记录锁不是SUS的一部分。在Linux上,如果你想要强制锁,你需要在mount命令上使用-o mand选项来为每个文件系统基于启用它。
强制锁为一个特定文件被启用,通过打开设置组ID位并关闭组执行位。因为设置组ID位在组执行位关闭时没有意义,SVR3的设计者选择这种方式来指定一个文件的锁是强制锁而不是建议锁。
当一个进程尝试读或写一个启用强制锁的文件,而文件的指定部分当前正被其它进程读锁或写锁时会发生什么呢?答案取决于操作的类型(读或写),另一个进程握住的锁的类型(读锁或写锁),和read或write的描述符是否是非阻塞的。下表展示了8种可能。
被另一个进程在区域上握住的已有的锁的类型 | 阻塞的描述符,尝试: | 非阻塞的描述符,尝试: | ||
read | write | read | write | |
读锁 | 没问题 | 阻塞 | 没问题 | EAGAIN |
写锁 | 阻塞 | 阻塞 | EAGAIN | EAGAIN |
除了上表的read和write函数,open函数也被其它进程握住的强制锁影响。通常,open成功,即使文件被外面的强制锁打开。接下来的read或write会遵循上表中的规则。但是如果文件被外面的强制记录锁打开,并且open调用的标志指定O_TRUNC或O_CREAT,那么open立即返回一个EAGAIN错误,不管O_NONBLOCK是否被指定。
只有Solaris把O_CREAT作为一个错误情况对待。Linux允许O_CREAT标志被指定,当打开一个有外部强制锁的文件时。为O_TRUNC产生open错误是有意义的,因为文件不能被裁切,如果它被其它进程读锁或写锁。然而,为O_CREAT产生错误没有什么意义;这个标志说只当文件不存在时才会创建这个文件,但是被其它进程锁的文件必须存在。
和open冲突的锁的处理会导致奇怪的结果。当开发本节的练习时,一个测试程序被运行,它打开一个文件(它的模式被指定为强制锁),在整个文件上建立一个读锁,然后睡一会。(回想上表读锁应该阻止其它进程的写。)在这个睡眠期间,以下的行为会在其它典型的UNIX系统程序里看到:
1、相同的文件可以被ed编辑器编辑,结果被写到磁盘里!强制锁完全没有效果。使用UNIX系统的某些版本提供的系统调用跟踪特性,可以看到ed把新的内容写到一个临时文件,删除了原始的文件,然后重命名这个临时文件为原始文件。强制锁在unlink函数上没有效果,它允许这个发生。在Solaris下,进程的系统调用跟踪可以由truss命令得到。FreeBSD和Mac OS X使用ktrace和kdump命令。Linux提供了strace命令来跟踪进程执行的线程调用。
2、vi编辑器无法编辑这个文件。它能读文件的内容,但是每当它想写入新数据时,EAGAIN被返回。如果我们尝试工向文件添加新数据,write阻塞。vi的行为是我们期望的。
3、使用Korn外壳的>和>>操作符来覆写或添加到文件导致错误“不能创建”。
4、在Bourne外壳里使用相同的两个操作符导致>的一个错误,但是>>操作符只阻塞,直到强制锁被删除,然后执行。(添加操作符的处理的区别在于因为Korn外壳open文件,使用O_CREAT和O_APPEND,我们之前提过指定O_CREAT产生一个错误。然而,Bourne外壳,没有指定O_CREAT如果文件已经存在,所以open成功但下一个write阻塞。)
根据你正使用的操作系统的版本,结果会不同。这个练习的底线是小心强制记录锁。正如ed例子里看到的,它可以被回避。
强制记录锁可以被恶意用户用来握住一个公开可读的文件上的读锁。这会阻止任何人写这个文件。(当然,文件必须启用强制记录锁才会发生这个,这需要这个用户有能够改变这个文件的权限位。)考虑一个数据库文件,它是所有人可读的并启用了强制记录锁。如果一个恶意用户要在整个文件上握住一个读锁,那么文件不能被其它进程写。
下面的程序确定一个系统是否支持强制锁。
如果我们看下系统头文件或intro手册页,我们看到11的errno对应于EAGAIN。在FreeBSD 5.2.1,我们得到:
$ ./a.out tmp.foo
read_lock of already-locked region returns 35
read OK (no mandatory locking), buf = ab
这里35的errno对应于EAGAIN。强制锁没有被支持。(我的Linux3.0也不支持强制锁,不知道是不是因为mount时没有选择这项。)
让我们回到本节的第一个问题:当两个人同时编辑同一个文件会发生什么?普通的UNIX系统文本编辑器不使用记录锁,所以答案是仍然为对应于最后写这个文件的进程的文件结果。
一些版本的vi使用建议记录锁。即使我们用某个这样的vi,它仍然无法阻止用户使用另一个不使用建议记录锁的编辑器。
如果系统提供强制记录锁,那么我们可以修改我们最喜爱的编辑器来使用它(如果我们有源码)。没有编辑器的源码时,我们可能做以下尝试。我们写自己的程序作为vi的前端。这个程序立即调用fork,而父进程只是等待子进程结束。子进程打开在命令行指定的文件,启用强制锁,得到整个文件的写锁,然后执行vi。当vi运行时,文件是被写锁的,所以其他用户不能修改它。当vi终止时,父进程的wait返回,而我们的前端终止。
一个小的这种类型的前端程序可以写出来,但是不能工作。问题是多数编辑器普遍读它们的输入文件然后关闭它。每当一个引用到文件的描述符被关闭时这个文件上的锁会被释放。这表示当编辑器在读取内容后关闭文件时,锁会丢失。在前端程序里无法阻止。
我们将在第20章使用记录锁,在我们的数据库库里来提供多个进程的并发访问。我们也将提供一些时间测量来看记录锁在一个进程上的影响。
14.4 STREAMS
STREAMS机制由系统V提供,作为进入内核的接口通信驱动的通常方法。我们需要讨论STREAMS来了解系统V的终端接口,I/O复用的poll函数(14.5.2节)的使用,和基于STREAMS的管道和命名管道(17.2和17.2.1节)的实现。
小心不要和我们在标准I/O库(5.2节)里使用的单词“stream”混淆。流机制由Dennis Ritchie开发作为清理传统字符I/O系统(c-lists)和适应网络协议的方法。流机制稍后被加入到SVR3,在增强了一点和优化名字之后。STREAMS的完整支持(一个基于STREAMS的终端I/O系统)在SVR4里提供。SVR4实现在[AT&T 1990d]里描述。Rago[1993]同时讨论了用户级STREAMS编程和内核级STREAMS编程。
STREAM是SUS的一个可选特性(包含在XSI STREAM可选组里)。本文讨论的四个平台,只有Solaris提供了STREAMS的本地的支持。STREAMS子系统在Linux上可用,但是你需要自己加上它。它通常不会默认包含。
一个流在一个用户进程和一个设备驱动之间提供了一个全双工(full-duplex)的道路。一个流没有必须和硬件设备交流,一个流也可以和一个伪终端设备驱动一起使用。下面展示被称为一个简单流的基本的图片:
在流头的下面,我们可以把处理模块推到流上面。这通过使用ioctl命令完成。下图展示了有单个处理模块的一个流。我们也展示了这些方块之间的连接,用两个箭头来强调流的全双工的本质,并强调一个方向的处理和另一个方向的处理是分开的。
可以把任意数量的处理模块推到一个流上。我们使用术语“推(push)”,因为每个新的模块都进到流头的下面,把之前任意推过的模块推下去。(这类似于后进先出的栈。)在上图,我们标出向上的流和向下的流。我们写到一个流头的数据是向下发送的;从设备驱动读的数据是向上发送的。
STREAMS模块和设备设务驱动相似,因为它们作为内核一部分执行,它们通常在内核编译时被链接到内核里。如果系统支持可动态加载的内核模块(如Linux和Solaris),那么我们可以使用一个还未链接到内核的STREAMS模块并把它推到一个流上;然而,没有保证说模块和驱动的任意结合可以恰当地在一起工作。
我们用第三章的函数open、close、read、write和ioctl来访问一个流。此外,SVR3内核加了三个新函数来支持STREAMS(getmsg、putmsg和poll),SVR4加入了另两个(getpmsg和putpmsg)来处理一个流的不同优先级带宽的消息。我们在本节稍后描述这五个函数。
我们为流open的pathname通常存在于/dev目录下。简单地用ls -l看下这个设备名,我们不能知道这个设备是否是一个STREAMS设备。所有STREAMS都是字符特殊文件。
尽管一些STREAMS文件暗示我们可以写处理模块并混乱地把它们推到一个流上,但是写这些模块需要和写一个设备驱动相同的技能和小心。通常,只有特定的应用或函数才会推或弹出STREAMS模块。
在STREAMS之前,终端用已有的c-list机制来处理。向内核加上一个基于字符的设备通常涉及写一个设备驱动器并把所有的东西放在驱动器里。访问新的设备通常是通过裸的设备,意味着每个用户read或write最终都直接进入设备驱动。STREAMS机制清理了这种交互的方式,允许数据在STREAMS消息里在流头和驱动之间流动,并允许任意数量的中间处理模块来操作这个数据。
STREAMS消息
所有在STREAMS下的输入和输出都是基于消息的。流头和用户进程用read、write、ioctl、getmsg、getpmsg、putmsg和putpmsg交换数据。消息也在流头、处理模块、和设备驱动之前被传上传下。
在用户进程和流头之间,一个消息由一个消息类型、可先控制信息,和可选数据组成。我们在下表中展示各种消息类型如何被write、putmsg和putpmsg的参数产生。
函数 | 控制? | 数据? | 带宽 | 标志 | 产生的消息类型 |
---|---|---|---|---|---|
write | 无 | 是 | 无 | 无 | M_DATA(普通) |
putmsg | 否 | 否 | 无 | 0 | 没有消息发送,返回0 |
putmsg | 否 | 是 | 无 | 0 | M_DATA(普通) |
putmsg | 是 | 是或否 | 无 | 0 | M_PROTO(普通) |
putmsg | 是 | 是或否 | 无 | RS_HIPRI | M_PCPROTO(高优先级) |
putmsg | 否 | 是或否 | 无 | RS_HIPRI | 错误,EINVAL |
putpmsg | 是或否 | 是或否 | 0-255 | 0 | 错误,EINVAL |
putpmsg | 否 | 否 | 0-255 | MSG_BAND | 没有消息发送,返回0 |
putpmsg | 否 | 是 | 0 | MSG_BAND | M_DATA(普通) |
putpmsg | 否 | 是 | 1-255 | MSG_BAND | M_DATA(优先带宽) |
putpmsg | 是 | 是或否 | 0 | MSG_BAND | M_PROTO(普通) |
putpmsg | 是 | 是或否 | 1-255 | MSG_BAND | M_PROTO(优先带宽) |
putpmsg | 是 | 是或否 | 0 | MSG_HIPRI | M_PCPROTO(高优先级) |
putpmsg | 否 | 是或否 | 0 | MSG_HIPRI | 错误,EINVAL |
putpmsg | 是或否 | 是或否 | 非0 | MSG_HIPRI | 错误,EINVAL |
控制消息和数据由strbuf结构体指定:
struct strbuf {
int maxlen; /* size of buffer */
int len; /* number or bytes currently in buffer */
char *buf; /* pointer to buffer */
};
当我们用putmsg或putpmsg发送一个消息时,len指定缓冲里的数据的字节数。当我们用getmsg或getpmsg接收消息时,maxlen指定缓冲的大小(所以内核不会溢出这个缓冲),而len由内核设置为存储在缓冲里的数据量。我们将看到一个0字长的消息是可以的,以及-1的len指明没有控制或数据。
为什么我们同时需要控制信息和数据?提供两者允许我们实现一个用户进程和一个流之间的服务接口。很可能最著名的服务接口,是系统V的传输层接口(Transport Layer Interface,TLI),它提供到网络系统的接口。
另一个控制信息的例子是发送一个无连接的网络消息(数据报)。为了发送消息,我们需要指定消息(数据)的内容和消息的目标地址(控制信息)。如果我们不能同时发送控制和数据,那么需要一些特别的策略。例如,我们可以用一个ioctl来指明地址,接着一个数据的write。另一个技术是要求数据占据write所写的的数据的前N字节。分离控制信息和数据,工提供处理两者的函数(putmsg和getmsg)是处理这个的更简洁的方法。
有大约25种不同类型的消息,但是只有很少被用在用户进程和流头之间。其它的在内核里的流里传上传下。(这些类型引起写STREAMS处理模块的人的兴趣,但可以被写用户级代码的人忽略。)我们将只碰到三种消息类型,在使用函数read、write、getmsg、getpmsg、putmsg和putpmsg时。
1、M_DATA(I/O的用户数据);
2、M_PROTO(协议控制信息);
3、M_PCPROTO(高优先级协议控制信息)。
一个流上的每个消息都有一个排除优先级:
1、高优先级消息(最高优先级);
2、优先带宽消息;
3、普通消息(最低优先级)。
普通消息简单地是0带宽的优先带宽消息。优先带宽消息有1-255的带宽,越高的带宽指定越高的优先级。高优先级带宽消息很特殊,因为它一次只有一个被流头排队。多余的高优先级消息被舍弃,当已经有一个在流头的读队列里时。
每个STREAMS模块有两个输入队列。一个接收上方模块的消息(消息从流头向驱动住下移动),一个接收下方模块的消息(消息从驱动向流头往上移动)。在一个输入队列上的消息根据优先级排列。我们在前面的表里展示了write、putmsg、和putpmsg的参数如何导致各种优先级消息的产生。
有其它我们没有考虑的消息类型。例如,如果流头从下方收到一个M_SIG消息,那么它产生一个信号。这是终端行处理模块如何向一个控制终端关联的前台进程组发送终端产生的消息。
putmsg和putpmsg函数
一个STREAMS消息(控制信息或数据,或两者)使用putmsg或putpmsg被写到一个流里。两个函数的区别是后者允许我们为消息指定一个优先级带宽。
#include
int putmsg(int filedes, const struct strbuf *ctlptr, const struct strbuf *dataptr, int flag);
int putpmsg(int filedes, const struct strbuf *ctlptr, const struct strbuf *dataptr, int band, int flag);
两者成功返回0,失败返回-1。
我们也可以write到一个流,它等价于不带任何控制信息和0的flag的putmsg。
这两个函数可以产生三种不同优先级的消息:普通、优先带宽、和高优先级。前面的表里详细介绍了产生各种消息类型的这两个函数的参数组合。
STREAMS ioctl操作
在3.15节,我们说过ioctl函数处理所有其它I/O函数不能完成的东西。STREAMS系统延续了这个传统。
在Linux和Solaris之间,有近40个不同的操作可以用ioctl在一个流上执行。这些操作的多数在streamio手册页的有文档。头文件
例子--isastream函数
我们有时需要确定一个描述符是否指向一个流。这类似于调用isatty函数确定一个描述符是否指向一个终端设备(18.9节)。Linux和Solaris提供了isastream函数。
#include
int isastream(int filedes);
如果是STREAMS设备返回1(true),否则返回0(false)。
就像isatty,这通常是一个简单的函数,仅仅尝试一个只在STREAMS设备上有效的ioctl操作。下面的代码展示这个函数的可能实现。我们使用I_CANPUT的ioctl命令,它检查第三个参数指定的带宽(这个例子为0)是否可写。如果ioctl成功,那么流不会发生变化。
/etc/motd: not a stream: Inappropriate ioctl for device
(我Linux 3.0上的结果为:
$ sudo ./a.out /dev/tty /dev/fb /dev/null /etc/motd
/dev/tty: not a stream: Success
/dev/fb: can't open: No such file or directory
/dev/null: not a stream: No such file or directory
/etc/motd: not a stream: No such file or directory)
注意/dev/ty是一个STREAMS设备,如我们在Solaris下所期望的。字符特殊文件/dev/fb不是一个STREAMS设备,但是它支持其它ioctl请求。这些调用在ioctl请求未知时返回EINVAL。字符特殊文件/dev/null不支持任何ioctl操作,所以错误ENODEV被返回。最后,/etc/motd是一个普通文件,不是一个字符特殊设备,所以典型的错误ENOTTY被返回。我们没有收到我们可能期望的错误:ENOSTR(“设备不是一个流”)。
ENOTTY的消息过去是“不是一个磁带打印机”,一个历史的制品,因为UNIX内核返回ENOTTY每当ioctl尝试在一个不指向一个字符特殊设备的描述符上时。这个消息在Solaris上被更新为“设备的不恰当的ioctl”。
如果ioctl请求是I_LIST,那么系统返回在流上的所有模块的名字--被推到流上的那些,包括最上方的驱动。(我们说最上方是因为在复用驱动的情况下,可能有多个驱动。)第三个参数必须是str_list结构体的指针。
struct str_list {
int sl_nmods; /* number of entries in array */
struct str_mlist *sl_modlist; /* ptr to first element of array */
};
我们必须设置sl_modlist来指向数组str_mlist结构体的数据的第一个元素,并设置sl_nmods为数组项的数量:
struct str_mlist {
char l_name[FMNAMESZ+1]; /* null terminated module name */
};
常量FMNAMESZ在头文件
如果ioctl的第三个参数是0,模块号的数量计数被返回(作为ioctl的值)而不是模块名。我们将使用这个来确定模块的数量,然后分配所需数量的str_mlist结构体。
下面的代码展示了I_LIST操作。因为返回的名字列表不区分模块和驱动,当我们打印模块名时,所以列表的最后项是流最底部的驱动。
(我Linux3.0上的运行结果是:
$ who
tommy pts/0 2012-03-17 10:50 (:0)
tommy pts/1 2012-03-17 14:28 (:0)
$ ./a.out /dev/pts/0
/dev/pts/0 is not a streamt
$ ./a.out /dev/pts/1
/dev/pts/1 is not a stream)
到STREAMS设备的write
在前面的表里我们说过一个到STREAMS设备的write产生一个M_DATA消息。尽管这通常是真的,但有些补充的细节要考虑。首先,对一个流,最顶端的处理模块指定可以向下发送的最小和最大的包尺寸。(我们不能查询模块来得到这些值。)如果write多于最大值,那么流头通常把数据分解成最大值尺寸的包,最后一个包比最大值小。
下一个要考虑的东西是如果我们write0字节到一个流时会发生什么。除非流指向一个管道或FIFO,否则0长度的消息被往下发送。对于管道或FIFO,默认是忽略0长度的write,为了和以前版本兼容。我们可以使用ioctl设置流的写模式来改变管道和FIFO的默认行为。
写模式
两个ioctl命令得到和设置一个流的写模式。设置request为I_GWROPT要求第三个参数是一个整型指针,流当前的写模式被返回在这个整型里。如果request是I_SWROPT,第三个参数是一个整型,其值变为流的新的写模式。和文件描述符标志和文件状态标志一样(3.14节),我们应该总是得到当前写模式再修改它,而不是设置模式为一些绝对的值(可能关掉已被启用的一些其它的位)。
当前,只有两个写模式值被定义:
SNDZERO:一个0长度的write到一个管道或FIFO会导致一个0长度的消息往下发送。默认下,这个0长度write不发送消息。
SNDPIPE:导致SIGPIPE被发送给调用write或putmsg的进程,在有流上的错误之后。
一个流也有读模式,在描述getmsg和getpmsg后再讨论。
getmsg和getpmsg函数
STREAMS消息使用read、getmsg或getpmsg从一个流头里读取。
#include
int getmsg(int filedes, struct strbuf *restrict ctlptr, struct strbuf *restrict dataptr, int *restrict flagptr);
int getpmsg(int filedes, struct strbuf *restrict ctlptr, struct strbuf *restrict dataptr, int *restrict bandptr, int *restrict flagptr);
两者成功返回非负数,错误返回-1。
注意flagptr和bandptr是整型指针。这两个指针指向的整数必须在调用之前设置来指定所需消息的类型,这个整型也在返回时被设置为读到消息的类型。
如果flagptr指向的整型为0,getmsg返回流头的读队列上的下一条消息。如果下一条消息是高优先级消息,那么flagptr指向的整型在返回时被设为RS_HIPRI。如果我们想只收到高优先级消息,那么我们必须设置flagptr所指的整型为RS_HIPRI,在调用getmsg之前。
getpmsg使用了不同的常量集。我们可以设置flagptr指向的整型为MSG_HIPRI来只收到高优先级消息。我们可以设置整型为MSG_BAND然后设置badptr所指的整型为非0优先级值来只接收那个带宽的值或更高的值的消息(包括高优先级消息)。如果我们只想接受第一个可用的消息,我们可以设置flagptr指向的整型为MSG_ANY;在返回时,这个整型将被覆写为MSG_HIPRI或MSG_BAND,取决于收到消息的类型。如果我们得到的消息不是一个高优先级消息,那么bandptr指向的整型会包含消息的优先级带宽。
如果ctlptr为空或ctlptr->maxlen为-1,那么消息的控制部分会留在流头的读队列上,我们将不会处理它。类似的,如果dataptr为空或dataptr->maxlen为-1,那么消息的数据部分不被处理并停留在流头的读队列上。否则,我们将得到和我们缓冲将容纳的一样多的数据的控制,任何剩余的会留在队列的头部,以便下次调用。
如果getmsg或getpmsg的调用得到一条消息,那么返回值为0。如果消息的控制部分有一部分留在流头的读队列上,那么常量MORECTL被返回。相似地,如果消息的数据部分有一部分留在队列上,那么常量MOREDATA被返回。如果控制和数据都有剩余,那么返回值为(MOREDATA|MOREDATA)。
读模式
我们也需要考虑如果我们从STREAMS设备读时会发生什么。有两个潜在问题。
1、和流上消息关联的记录边界会发生什么?
2、如果我们调用read而下一个流上的消息有控制信息会发生什么?
情况1的默认处理被称为字节流模式。在这种模式里,一个read从流中拿数据,直到请求的字节数被读取或直到没有更多数据。和STREAMS消息相关的消息边界在这种模式被忽略。情况2的默认处理导致read返回一个错误,如果在队列开头有一个控制消息。我们可以改变这些默认行为。
使用ioctl,如果我们设置request为I_GRDOPT,那么第三个参数是一个整型指针,流的当前读模式被返回在这个整型里。一个I_SRDOPT的request拿到第三个参数的整型值,并设置读模式为这个值。读模式由以下三个常量的某一个指定:
RNORM:普通的字节流模式(默认),如之前描述的。
RMSGN:不舍弃消息模式,一个read从流里拿数据,直到碰到边界。如果read使用一个部分消息,消息里的剩余数据为后续read留在流上。
RMSGD:消息舍弃模式。和不舍弃模式相似,但如果部分消息被使用,那么剩余的消息被舍弃。
三个补充的常量可以在读模式里指定,来设置read的行为,当它碰到包含在一个流上的协议控制信息时。
RPROTNORM:协议普通模式:read返回一个EBADMSG错误。这是默认值。
RPROTDAT:协议数据模式:read返回控制部分作为数据。
RPROTDIS:协议舍弃模式:read舍弃控制信息,但返回消息里的任何数据。
一次只有一个消息读模式和一个协议读模式可以被设置。默认的读模式为(RNORM|RPROTNORM)。
下面的代码和3.9节的程序相同,但是用getmsg而不是read
程序在用STREAMS同时实现管道和终端的Solaris下运行时,结果为:
$ echo hello, world | ./a.out
flag = 0, ctl.len = -1, dat.len = 13
hello, world
flag = 0, ctl.len = 0, dat.len = 0
$ ./a.out
this is line 1
flag = 0, ctl.len = -1, dat.len = 15
this is line 1
and line 2
flag = 0, ctl.len = -1, dat.len = 11
and line 2
^D
flag = 0, ctl.len = -1, dat.len = 0
$ ./a.out < /etc/motd
getmsg error: Not a stream device
当管道被关闭时(当echo终止时),看起来上面的代码是一个STREAMS挂起,控制长度和数据长度都被设为0.(15.2节讨论管道。)然而对于终端,输入文件终止符导致只有数据长度被设为0。终端的文件终止符和STREAMS挂起不同。正如意料,当我们重定向标准输入到一个非STREAMS设备时,getmsg返回一个错误。
(我的Linux3.0所有操作都返回错误。)
14.5 I/O 复用(I/O Multiplexing)
当我们从一个描述符里读并写到另一个时,我们可以在一个循环里使用阻塞I/O,比如:
while ((n = read(STDIN_FILENO, buf, BUFSIZ)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
err_sys("write error");
我们一次又一次看到这种形式的阻塞I/O。如果我们必须从两个描述符里读是怎么样呢?在这种情况下,我们不能在描述符上执行阻塞read,因为在我们阻塞在一个描述符上的read上时,数据可以出现在另一个描述符上。需要一个不同的技术来处理这种情况。
让我们看下telnet命令的结构。在这个程序里,我们从终端(标准输入)里读,并写到一个网络连接,以及从网络连接上读并写到终端(标准输出)。在网络连接的另一端,telnetd守护进程读我们输入的东西,并展示给外壳,好像是我们登录了一个远程机器一样。telnetd守护进程把任何由我们输出的命令产生的输出发送回我们,通过telnet命令,来显示到我们的终端。
telnet进程有两个输入和两个输出。我们不能在各个输入上执行一个阻塞read,因为我们不知道哪个输入会有我们的数据。
处理这种特殊问题的方法是把进程分为两块(使用fork),每半部分处理一个方向的数据。(系统V的uucp通信包提供的cu命令有类似的结构。)
如果我们使用两个进程,我们可以让每个进程执行一个阻塞read。但是这导致一个问题,当操作终止时。如果子进程收到一个文件末尾(telnetd守护进程断开了网络连接),那么子进程然后终止,父进程被SIGCHLD信号通知。但是如果父进程终止(用户在终端输入文件末尾),那么父进程然后必须告诉子进程停止。我们可以使用一个信号来处理(例如SIGUSR1),但是这确实复杂化了程序。
作为两个进程的替代,我们可以使用单个进程里的两个线程。这避免了终止的复杂,但要求我们处理线程间的同步,它加入比所节省的更多的复杂性。
我们可以在单个进程里使用非阻塞I/O,能把两个描述符设置为非阻塞的,并在第一个描述符上执行一个read。如果数据出现,我们读取它并处理它。如果没有数据可读,调用立即返回。我们然后为第二个描述符做相同的事。在此之后,我们等待一段时间(可能几秒),然后尝试再次从第一个描述符读。这种类型的循环被称为轮询。问题是它浪费CPU时间。多数时间,没有数据可读,所以我们浪费时间在执行read系统调用上。我们也必须猜想在每次循环里要等多久。尽管它在任何支持非阻塞I/O的系统上工作,但是轮循应该在多任务系统上避免。
另一个技术被称为异步I/O。为了执行它,我们告诉内核用一个信号通知我们,当一个描述符已经准备好I/O时。这样做有两个问题。首先,不是所有系统都支持这个特性(这是SUS的可选特性)。系统V为这个技术提供了SIGPOLL信号,但是信号只在描述符指向一个STREAMS设备时才工作。BSD有类似的信号,SIGIO,但是它有相似的限制:它只当描述符指向一个终端或网络时才会工作。这个技术的第二个问题是每个进程只有一个这样的信号(SIGPOLL或SIGIO)。如果我们为两个描述符启用这个信号,(在我们提过的例子里,从两个描述符里读),信号的发生不告诉我们哪个描述符准备好了。为了确定哪个描述符已准备,我们仍需要设置每个为非阻塞的,然后依次尝试它们。我们在14.6节简单讨论异步I/O。
一个更好的技术是使用I/O复用。要这样做,我们建立一个我们感兴趣的描述符列表(通常多于一个)并调用一个不返回的函数,直到某个描述符准备好I/O。在从这个函数返回时,我们被告知哪个描述符准备好I/O了。
三个函数--poll、pselect和select--允许我们执行I/O复用。下表总结了哪些平台支持它们。注意select由基于POSIX.1标准定义,但poll是基础的一个XSI扩展。
系统 | poll | pselect | select | |
---|---|---|---|---|
SUS | XSI | * | * | * |
FreeBSD | * | * | * | |
Linux2.4.22 | * | * | * | * |
Mac OS X 10.3 | * | * | * | |
Solaris 9 | * | * | * |
I/O复用随着select函数由4.2BSD提供。这个函数总是和任何描述符都工作,尽管它的主要使用是为了终端I/O和网络I/O。SVR3加上了poll函数,当STREAMS机制被加入时。然而,最开始poll只对STREAMS设备有用。在SVR4里,允许poll工作在任何描述符上的支持被加入。
14.5.1 select和pselect函数
select函数让我们执行I/O复用,在所有POSIX平台下。我们传入select的参数告诉内核
1、我们对哪个描述符感兴趣;
2、为每个描述符我们对什么条件感兴趣。(我们想要从一个给定的描述符读吗?我们想向一个给定的描述符写吗?我们对一个给定的描述符的一个例外条件感兴趣吗?)
3、我们想等待多久。(我们可以永远地等,等待一段时间,或完全不等。)
在从select返回时,内核告诉我们
1、已经准备好的描述符的数量的总计数。
2、哪些描述符准备好三种情况(read、write或例外条件)。
有了返回信息,我们可以调用恰当的I/O函数(通常read或write)并知道哪些函数不会阻塞。
#include
int select (int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict exceptfds, struct timeval *restrict tvptr);
返回准备好的描述符的计数,超时返回0,错误返回-1。
让我们首先看下最后的参数。这指明我们想要等待多久:
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* and microseconds */
};
有三种情况。
tvptr == NULL:永远等待。这个无尽的等待可以被中断,如果我们捕获一个信号。当某个指定的描述符准备好或当一个信号被捕获时会返回。如果一个信号被捕获,那么select返回-1,errno设为EINTR。
tvptr->tv_sec == 0 && tvptr->usec == 0:不要等待,所有指定的描述符被测试,然后立即返回。这是轮询系统来找到多个描述符状态而不在select函数里阻塞的一种方法。
tvptr->tv_sec != 0 || tvptr->tv_usec != 0:等待指定的秒数和毫秒数。当某个指定的描述符准备好或计时过期时返回。如果在符合要描述符准备好之前计时过期,那么返回值为0。(如果系统不提供微秒的精度,那么tvptr->tv_usec直会约到最近的支持的值。)和第一种情况一样,这个等待也被一个捕获的信号中断。
POSIX.1允许一个实现修改timeval结构,所以在select返回时,你不能假设结构体和在调用select之前包含相同的值。FreeBSD、Mac OS X、Solaris都保持结构体不变,但是Linux用剩余时间更新它,如果select在计时过期之前返回。
中间三个参数--readfds、writefds、和exceptfds--是描述符集的指针。这三个集合指定我们对哪些描述符,以及哪个情况(可读、可写或一个异常条件)感兴趣。一个描述符集被存储在一个fd_set数据类型里。这个数据类型由实现选择以便它能为每个可能的描述符存储一个位。我们可以只视它为一个大的位数组。
我们对fd_set数据类型唯一可以做的事情是分配一个这种类型的变量,把一个这样类型的变量赋给另一个相同类型的变量,或使用下面四个函数的某个来处理这种类型的变量。
#include
int FD_ISSET(int fd, fd_set *fdset);
如果fd被设置返回非0,否则返回0。
void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);
这些接口可以作为函数或宏实现。一个fd_set被设为所有0位,通过调用FD_ZERO。为了打开一个集合里的单个位,我们使用FD_SET。我们可以调用FD_CLR来清除单个位。最后,我们可以测试一个给定位是否在集合里被打开,使用FD_ISSET。
在声明一个描述符集后,我们必须清零这个集合,使用FD_ZERO。我们然后为每个感兴趣的描述符在集合里设置位,就如:
fd_set rset;
int fd;
FD_ZERO(&rset);
FD_SET(fd, &rset);
FD_SET(STDIN_FILENO, &rset);
在从select返回时,我们可以用FD_ISSET测试一个给定的位是否仍然开启:
if (FD_ISSET(fd, &rset)) {
...
}
select的中间三个参数(描述符集的指针)中的任一个(或全部)可以是空指针,如果我们不对那个情况感兴趣。如果所有三个指针都是NULL,那么我们有一个比sleep提供的更高精度的计时器。(回想10.19节slee等待整数秒数。有了select,我们可以等待比1秒更小的间隔;真实的精度取决于系统的时钟。)
select的第一个参数,masfdp1,表示“最大文件描述符加1.”我们计算我们感兴趣的最高的描述符,包括所有三个描述集,加上1,就是第一个参数。我们可以只设置第一个参数为FD_SETSIZE,一个在
例如,如果我们这样写:
fd_set readset, writeset;
FD_ZERO(&readset);
FD_ZERO(&writeset);
FD_SET(0, &readset);
FD_SET(3, &readset);
FD_SET(1, &writeset);
FD_SET(2, &writeset);
select(4, &readset, &writeset, NULL, NULL);
那么,描述符4以上(包括4)的位都不会被检查。
我们必须在最大描述符号上加1的原因是描述符从0开始,而第一个参数实际上是要检查的描述符的数量的计数(从描述符0开始)。
select有三种可能的返回值。
1、一个-1的返回值表示一个错误发生。例如,如果信号在任何指定的描述符准备好前被捕获,这可能发生。在这种情况下,没有一个描述符集会被修改。
2、一个0的返回值表示没有描述符准备。这在时间限制过期时没有任何描述符准备好时发生。当这个发生时,所有的描述符集都将被清0.
3、一个正的返回值指明准备好的描述符的数量。这个值是在所有三个集里面准备好的描述符的和,所以如果相同的描述符准备好读和写,那么它将在返回时值计算两次。唯一留在这三个描述符集里的位只有那么对应对准备好的描述符的。
我们现在需要更明确的“准备好”的意思。
1、一个在读集合(readfds)里的描述符被视为准备好,当从那个描述符的一个read不会阻塞。
2、一个在写集合(writefds)里的描述符被视为准备好,当向那么描述符的一个write不会阻塞。
3、一个在异常集全(exceptfds)里描述符被视为准备好,当一个异常情况被添加到那个描述符。当前,一个对应于在网络连接上超过边界的数据的到达或在被置为包模式的伪终端的特定情况发生时。
4、普通文件的文件描述符问题返回为读、写和异常条件准备好。
意识到一个描述符是否是阻塞的不会影响select是否阻塞是很重要的。那是说,如果我们有一个非阻塞的描述符,我们想从它读,我们调用带有5秒计时的select,select会最多阻塞5秒。类似的,如果我们执行一个无尽的计时,select会阻塞,直到描述符的数据准备好,或一个信号被捕获。
如果我们碰一个描述符的文件尾,那么那个描述符被select视为可读。我们然后调用read,它返回0,在UNIX系统上表示文件末尾的方法。(许多人不正确地假设select当文件末尾到达时,会指出一个在描述符上的异常条件。)
POSIX.1也定义了select的变体,称为pselect。
#include
int pselect(int maxfdp1, fd_set *restrict readfds, fd_set *restrict wrtefds, fd_set *restrict exceptfds, const struct timespec *restrict tsptr, const sigset_t *restrict sigmask);
返回准备好的描述符的计数,到时返回0,错误返回-1
pselect函数和select一样,除了以下的例外:
1、select的计时值被一个timeval结构体指明,但是对于pselect,一个timespec结构体被使用。(回想11.6节timespec结构体的定义。)作为秒和微秒的替代,timespec结构体以秒和纳秒表示计时值。这提供了更高精度的计时,如果平台支持这种粒度的话。
2、pselect的计时值被声明为const,我们被保证它的值不会因为pselect的调用被改变。
3、一个可先的信号掩码参数在pselect里可用。如果sigmask为空,那么pselect和select对待信号的行为一样。否则,sigmask指向一个信号掩码,它在pselect调用时被自动安装。返回时,之前的信号掩码被恢复。
14.5.2 poll函数
poll函数和select相似,除程序员接口不同。正如我们将看到的,poll和STREAMS系统绑定,因为它起源于系统V,尽管我们可以在其它类型的文件描述符上使用它。
#include
int poll(struct poolfd fdarray[], nfds_t nfds, int timeout);
返回准备好的描述符的计数,到时返回0,错误返回-1。
使用poll,我们不必为每个条件(读、写和异常条件)建立一堆描述符,如我们用select做的,我们建立一个pollfd结构体的数据,每个数组元素指定一个描述符号和我们对那个描述符感兴趣的条件。
struct pollfd {
int fd; /* file descriptor to check, or <0 to ignore */
short events; /* event of interest on fd */
short revents; /* event that occurred on fd */
};
在fdarray数组里的元素数由nfds指定。
历史上,对于nfds参数如何被声明有几种不同的方式。SVR3指定数组里的元素个数作为一个无符号长整型,它看起来过大。在SVR4手册里,poll的原型展示了第二个参数的数据类型为size_t。(回想第二章原始系统数据类型。)但是
对应于SVR4的SVID显示poll的第一个参数为struct pollfd fdarray[],而SVR4手册面显示这个参数为struct pollfd *fdarray。在C语言,两个声明是一样的。我们使用第一个声明来表示fdarray指向一个结构体数组,而不是单个结构体。
为了告诉肉体我们在每个描述符上对什么事件感兴趣,我们必须设置每个数组元素的events成员为下表的一个或多个值。在返回时,revents成员被内核设置,指明每个描述符上的哪些事件已发生。(注意poll不改变events成员。这和select不同,它修改它的参数来表示什么已经准备好了。)
名字 | events的输入? | revents的结果? | 描述 |
---|---|---|---|
POLLIN | * | * | 除了高优先级之外的数据可以不被阻塞地读,等价于(POLLRDNORM|POLLRDBAND)。 |
POLLRDNORM | * | * | 普通数据(优先带宽为0)可以无阻塞地读。 |
POLLRDBAND | * | * | 非0优先带宽的数据可以无阻塞地读。 |
POLLPRI | * | * | 高优先级数据可以无阻塞地读。 |
POLLOUT | * | * | 普通数据可以可以无阻塞地写。 |
POLLWRNORM | * | * | 和POLLOUT一样。 |
POLLWRBAND | * | * | 非0优先带宽的数据可以可以无阻塞地写。 |
POLLERR | * | 一个错误发生。 | |
POLLHUP | * | 一个挂起发生。 | |
POLLNVAL | * | 描述符没有指向一个打开的文件。 |
当一个描述符被挂起(POLLHUP)时,我们不再能够写入这个描述符。然而,仍然可能有数据从这个描述符上读。
poll的最后参数指明我们想要等待多久。和select一样,有三种情况。
timeout == -1:永远等待。(一些系统在
timeout == 0:不等待。所有指定的描述符被测试,我们立即返回。这是轮询系统来找到多个描述符状态而不阻塞poll调用的方法。
timeout > 0:等待timeout毫秒。我们当某个指定的描述符准备好或当timeout过期时返回。如果timeout在任何描述符准备好之前过期,那么返回值为0。(如果你的系统不提供毫秒精度,timeout会向上约到最近的支持的值。)
意识到文件末尾和挂起的区别是重要的。如果我们不从终端进程并输入文件末尾符,那么POLLIN被打开把以我们可以讲到文件末尾符(read返回0)。POLLHUP在revents里不被打开。如果我们从一个挂起的猫或电话线上读,那么我们收到POLLHUP通知。
和select一样,一个描述符是否阻塞不影响poll阻塞。
select和poll的中断能力
当中断的系统调用的自动重启在4.2BSD引入时(10.5节),select函数从未被重启。这个特性在多数系统上延续,即使SA_RESTEART选项被指定。但是在SVR4下,如果SA_RESTART被指定,那么甚至select和poll会自动重启。为了避免这个影响我们,当我们移植软件到SVR4的后代系统时,我们总是使用signal_intr函数,如果信号可以中断一个select或poll调用。
本文四个实现没有一个重启poll或select,当一个信号被收到时,即使SA_RESTART标志被使用。
14.6 异步I/O
使用select和poll,如前一节所述的,是一个同步形式的通知。系统不告诉我们任何事,直到我们问(通过select或poll的调用)。如我们在第10章看到的,信号提供了一个异步形式的通知,当某些事发生时。所有从BSD和系统V继承的系统提供一些形式的异步I/O,使用一个信号(系统V上的SIGPOLL;BSD上的SIGIO)来通知进程一些感兴趣的东西在一个描述符上发生出。
我们看到select和poll工作于任何描述符上。但是使用异步I/O,我们现在碰到限制。在从系统V继承的系统上,异步I/O只工作在STREAMS设备和STREAMS管道。在BSD继承的系统上,异步I/O只工作于终端和网络。
异步I/O的一个限制是一个进程只有一个信号。如果我们为异步I/O开启多个描述符,我们不能知道哪个描述符对应于被分发的信号。
SUS包含了一个可选的通用异步I/O机制,从实时草稿标准中采纳。这和我们在这里描述的机制无关。这个机制解决了许多存在于早期异步I/O机制的限制,但是我们不再更深讨论它。
14.6.1 系统V异步I/O
在系统V,异步I/O是STREAMS系统的一部分,并只工作在STREAMS设备和STREAMS管道上。系统V异步I/O信号是SIGPOLL。
为了给一个STREAMS设备启用异步I/O,我们必须调用第二个参数(request)为I_SETSIG的ioctl。第三个参数是一个整型值,由下表的一个或多个常量组成。这些常量定义在
常量 | 描述 |
---|---|
S_INPUT | 一个不是高优先级消息的消息到达。 |
S_RDNORM | 一个普通消息到达。 |
S_RDBAND | 一个非0优先带宽消息到达。 |
S_BANDURG | 如果常量由S_RDBAND指定,SIGURG信号代替SIGPOLL产生,当非0优先带宽消息到达时。 |
S_HIPRI | 一个高优先级消息到达。 |
S_OUTPUT | 写队列不再满。 |
S_WRNORM | 和S_OUTPUT一样。 |
S_WRBAND | 我们可以发送非0优先带宽消息。 |
S_MSG | 包含SIGPOLL信号一个STREAMS信号消息到达。 |
S_ERROR | 一个M_ERROR消息到达。 |
S_HANGUP | 一个M_HANGUP消息到达。 |
除了调用ioctl来指定应该产生SIGPOLL信号的情况,我们还必须为这个信号建立一个信号处理机。回想第10章SIGPOLL的默认动作是终止进程,所以我们应该在调用ioctl之前设立这个信号处理机。
14.6.2 BSD异步I/O
BSD后代系统里的异步I/O是两个信号的组合:SIGIO和SIGURG。前者是通用异步I/O信号,而后者只用来通知超过带宽的数据在一个网络连接上到达。
为了接收SIGIO信号,我们需要执行三个步骤。
1、设立一个SIGIO的信号处理机,通过调用signal或sigaction。
2、设置描述符信号的进程ID或进程组ID,通过用一个F_SETOWN命令调用fcntl(3.14节)。
3、在描述符上启用异步I/O,通过用命令F_SETFL调用fcntl来设置O_ASYNC文件状态标志。
步骤3只可以在指向终端或网络的描述符上执行,它是BSD异步I/O设施的基本限制。
对于SIGURG信号,我们只需要执行步骤1和步骤2。SIGURG只为指向支持带宽外数据的网络连接的描述符产生。
14.7 readv和writev函数
readv和write函数让我们在单个函数调用里从多个不连续的缓冲里读入或写出。这些操作被称为分散读(scatter read)和集合写(gather write)。
#include
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
两者都返回读或写的字节数,错误返回-1。
两个函数的第二个参数都是一个iovec结构体数组的指针:
struct iovec {
void *iov_base; /* starting address of buffer */
size_t iov_len; /* size of buffer */
};
iov数组里的元素数量由iovcnt指定。它限制于IOV_MAX(第二章)。下图显示了这两个函数的参数和iovec结构体的关系:
writev函数把缓冲的输出数据按顺序集合到一起:iov[0]、iov[1]、到iov[iovcnt-1];writev返回输出字节的总数量,它应该等于所有缓冲长度的和。
readv函数把数据按顺序分散到缓冲里,问题在处理下一个缓冲时填满第一个。readv返回被读的字节总数。如果没有更多数据和碰到文件末尾时返回0的计数。
这两个函数起源于4.2BSD,后来加入到SVR4。这两个函数被SUS的XSI扩展包含。
尽管SUS定义了缓冲地址为一个void *,然而许多未跟上标准的实现仍使用char *代替。
在20.8节里,函数_db_writeidx里,我们需要连续地写两个缓冲到一个文件。第二个要输出的缓冲是一个传给调用者的参数,而第一个缓冲是我们创建的,包含第二个缓冲的长度和文件里其它信息的一个文件偏移量。我们可以有三种方法做这个。
1、调用write两次,一个缓冲一次;
2、分配一个我们自己的缓冲,它足够大来包含两个缓冲,然后把两者拷贝到新缓冲。我们然后为这个新缓冲调用write一次。
3、调用writev来输出两个缓冲。
我们在20.8节使用的解决方案是使用writev,但是把它跟其它两种方案比较是有指导性的。
下表显示了提到的三种方法的结果。
操作 | Linux (Intel x86) | Mac OS X (PowerPC) | ||||
用户 | 系统 | 时钟 | 用户 | 系统 | 时钟 | |
两次write | 1.29 | 3.15 | 7.39 | 1.60 | 17.40 | 19.84 |
缓冲拷贝,然后一次write | 1.03 | 1.98 | 6.47 | 1.10 | 11.09 | 12.54 |
一次writev | 0.70 | 2.72 | 6.41 | 0.86 | 13.58 | 14.72 |
正如我们意料的,系统时间当我们调用两次write时增加,对比于一次的write或writev。这和3.9节的测量结果对应。
接着,注意CPU时间的总和(用户加上系统),当我们执行缓冲拷贝接着单个write比单个writev调用要少。对于单个write,我们在用户层拷贝缓冲到一个分段运输的缓冲,然后在我们调用write时内核会把数据拷贝到它内部的缓冲。对于writev,我们应该做更少的拷贝,因为内核只需要直接把数据拷贝到它的分段运输缓冲里。然而,为如此少的数据使用writev的固定花费,比所得要大。当我们需要拷贝的数据量增加时,在我们程序里拷贝缓冲会便耗时,而writev替代地将更具吸引力。
小必不要被上表中Linux相对于Mac的性能影响太多。这两个电脑非常不同:它们有不同的处理器架构、不同的RAM量、不同速度的磁盘。为了公平比较两个操作系统,我们需要使用相同的硬件。
总而言之,我们应该总是尝试使用所需的最少次数的系统调用来完成工作。如果我们写少量数据,那么我们将发现自己拷贝数据和使用单个write而不是使用writev会更不耗时。然而,我们可能发现,性能的好处比不上需要管理我们自己的分段运输缓冲的复杂性代价。
14.8 readn和writen函数
管道、FIFO和一些设备,尤其是终端、网络和STREAMS设备,有以下两个属性。
1、一个read操作可能返回比请求更少,即使我们没有遇到文件尾。这不是一个错误,我们应该简单地继续从设备读。
2、一个write操作也可能返回比我们指定的少。例如它可能因为向下的流模块的控制流限制。再次,这不是一个错误,我们应该继续写剩余的数据。(通常,这从一个write而来的短返回只在非阻塞描述符或如果一个信号被捕获是发生。)
我们将不会在读写磁盘文件时看到这个发生,除了发文件系统没有空间,或我们达到了配额限制而我们不能全部写完我们请求的。
一般说来,当我们读或写一个管道、网络设备或终端时,我们需要考虑这些特性。我们可以使用以下两个函数来读写N字节的数据,让这些函数处理一个可能的比请求小的值。这两个函数简单地调用read或write,尽可能多次地来读写整个N字节的数据。
#include "apue.h" /* 自己写的函数 */
ssize_t readn(int filedes, void *buf, size_t nbytes);
ssize_t writen(int filedes, void *buf, size_t nbytes);
两者返回读或写的字节数,错误返回-1。
我们定义这些函数,作为以后例子的便利工具,和本文许多例子里使用的错误处理例程一样。readn和writen函数不是任何标准的一部分。
我们调用writen,每当我们向某个我们提到的文件类型写时,但是我们只在我们提前知道我们将收到特定字节数量时才调用readn。下面的代码展示了readn和writen的实现,我们将在后面的例子里使用它们。
14.9 内存映射I/O(Memory-Mapped I/O)
内存映射I/O让我们把磁盘文件映射到内存的一个缓冲里,以便当我们得到缓冲里的字节时,文件对应的字节会被读。相似的,当我们在缓冲存储数据时,对应的字节被自动地写到这个文件。这让我们执行I/O而不使用read或write。
内存映射I/O在虚拟内存系统使用了很多年。在1981年,4.1BSD提供了不同形式的内存映射I/O,用它的vread和vwrite函数。这两个函数在4.2BSD被删除,意图用mmap函数代替。然而,mmap函数没有包含在4.2BSD里。mmap函数包含在SUS里的内存映射文件可选项里,并要求所有XSI系统支持。多数UNIX系统支持它。
为了使用这个特性,我们必须告诉内核把一个给定的文件映射到内核的一个区域。这通过mmap函数完成。
#include
void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off);
成功返回被映射区域的开始地址,错误返回MAP_FAILED。
addr参数让我们指定我们想映射区域开始的地址。我们通常设置它为0来允许系统选择开始地址。这个函数返回映射区域的开始地址。
filedes参数是指定被映射的文件的描述符。我们必须在能够映射它到地址空间之前打开这个文件。len参数是映射的字节数,off是要映射的文件的字节偏移量。(off值上的一些限制稍后会描述。)
prot参数指定映射区域的保护。
PROT_READ:区域可读。
PROT_WRITE:区域可写。
PROT_EXEC:区域可被执行。
PROT_NONE:区域不能被访问。
我们可以用PROT_NONE或PROT_READ、PROT_WRITE、和PROT_EXEC的任意位或组合来指定保护。为区域指定的保护不能允许比文件的打开模式更多的访问。例如,我们不能指定PROT_WRITE,如果文件被只读民打开。
在看flag参数之前,让我们看下这里发生了什么。映射的内存在堆和栈之间的某个位置:这是实现细节,不同实现可能会有不同。
flag参数影响了映射区域的各种属性。
MAP_FIXED:返回值必须等于addr。使用这个标志不被鼓励,因为它阻碍了移植性。如果这个标志没有被指定,且addr非0,那么内核使用addr作为哪里放置映射区域的一个提示,但是没有保证请求的地址会被使用。最大可移植性通过指定addr为0来获得。支持MAP_FIXED标志在POSIX系统上可选,但是在XSI系统是必需。
MAP_SHARED:这个标志描述了这个进程进入这个映射区域的存储操作的布署。这个标志指明存储操作修改映射文件--也就是说,一个存储操作等价于一个文件的write。这个标志或下面一个(MAP_PRIVATE),但不是同时,必须被指明。
MAP_PRIVATE:这个标志说进入映射区域的存储操作导致映射文件的一份私有拷贝被创建。所以后续对映射区域的引用都会引用到这份拷贝。(这个标志的一种用法是,调试者映射一个程序文件的代码部分但允许用户修改指令。任何修改都影响这份拷贝,而不是原始程序文件。)
每个实现都有补充的MA_XXX标志值,它们依赖于那个实现。检查mmap手册页获得更多细节。
off的值和addr的值(如果MAP_FIXED被指定)需要是系统虚拟内存页尺寸的倍数。这个值可以从_SC_PAGE_SIZE得到。因为off和addr经常被指定为0,所以这个需求通常不是什么大事。
因为映射文件的开始偏移量被绑定到系统的虚拟内存页尺寸,如果映射区域的长度不是页尺寸的倍数会怎么样呢?假定文件尺寸是12字节,系统页尺寸是512字节。在这种情况下,系统通常提供一个512字节的映射区域,而这个区域的最后500字节被设为0。我们可以修改最后500字节,但是任何我们对它们作出的改变都不会反映到那个文件里。因而,我们不能用mmap添加到文件。我们必须首先增加文件的尺寸,如我们将在后面看到的。
两个信号通常和映射区域一起使用。SIGSEGV是通常用来指定我们尝试了访问对我们不可用的内存的信号。这个信号也可以在我们尝试向在mmap里指定为只读的映射区域存储数据时产生。SIGBUS信号在我们访问的映射区域的那部分在被访问时无意义时产生。例如,假定我们用文件尺寸映射一个文件,但是在我们引用被映射的区域之前,文件被其它一些进程裁切了。如果我们尝试访问对应于已被裁切的文件的末尾部分的内存映射区域,那么我们会收到SIGBUS。
一个内存映射区域通过一个fork被子进程继承(因为它是父进程的地址空间的部分),但由于相同的原因,它不通过exec被新程序继承。
我们可以调用mprotect来改变一个已有映射上的权限。
#include
int mprotect(void *addr, size_t len, int prot);
成功返回0,错误返回-1。
prot的合法值和mmap的那些相同。地址参数必须是系统页的整数倍。
mprotect函数包含在SUS的内存保护可选项里,但XSI系统需要支持它。
如果共享映射里的页被修改,我们可以调用msync来冲洗对被映射文件的改变。msyc函数和fsync(3.13节)相似,但工作在内存映射区域上。
#include
int msync(void *addr, size_t len, int flags);
成功返回0,错误返回-1。
如果映射是私有的,那么映射的文件不会被修改。和其它内存映射函数一样,地址必须对齐到一个页边界上。
flags参数允许我们有一些关于内存如何被冲洗的控制。我们可以指定MS_ASYNC标志来简单地安排这些页被写。如果我们想在返回前等待写的完成,我们可以使用MS_SYNC标志。MS_ASYNC或MS_SYNC必须有一个被指定。
一个可选标志,MS_INVALIDATE,让我们告诉操作系统舍弃任何没有同步到底下存储的页。当我们使用这个标志时,一些实现会舍弃在指定范围内的所有页,但这个行为不被要求。
一个内存映射区域被自动反映射,当 进程终止或直接调用munmap。关闭文件描述符filedes不会反映射这块区域。
#include
int munmap(caddr_t addr, size_t len);
成功返回0,错误返回-1。
munmap不影响被映射的对象--也就是说,munmap的调用不导致映射区域的内容被写到磁盘文件。为MAP_SHARED区域的磁盘文件的更新自动被内核的虚拟内存算法发生,当我们存储到内存映射区域时。在MAP_PRIVATE区域里的修改会被舍弃,当区域被反映射时。
下面的代码拷贝一个文件(类似于cp命令),使用内存映射I/O。
本文四个平台的ftruncate都会扩展一个文件。
我们然后为每个文件调用mmap,把文件映射到内存,最后调用memcpy把输入缓冲拷贝到输出缓冲。当数据的字节从输入缓冲(src)中取出时,输入文件自动被内核读取;当数据被存储到输入缓冲(dst)时,数据被自动写到输出文件。
数据确切何时写入文件,取决于系统的页管理算法。一些系统有不时慢慢写脏页到磁盘的守护进程。如果我们想保证数据被安全写到一个文件,我们需要调用在退出前用MS_SYNC标志调用msync。
让我们比较内存映射文件拷贝和调用read和write的拷贝(8192的缓冲尺寸)。下表显示了结果。时间以秒为单位,被拷贝的文件的尺寸是300M。
操作 | Linux (Intel x86) | Solaris (SPARC) | ||||
用户 | 系统 | 时钟 | 用户 | 系统 | 时钟 | |
read/write | 0.04 | 1.02 | 39.76 | 0.18 | 9.70 | 41.66 |
mmap/memcpy | 0.64 | 1.31 | 24.26 | 1.68 | 7.94 | 28.53 |
对于Solaris 9,总的CPU时间(用户+系统)对于两种类型的拷贝近乎相同:9.88秒和9.62秒。对于Linux 2.4.22,使用mmap和memcpy的总的CPU时间几乎是两倍(1.06秒和1.95秒)。区别很可能是因为两个实现有不同的进程计时方式。
考虑到逝去时间,mmap和memcpy的版本比read和write的版本更快。这说得通,因为我们用mmap和memcpy做了更少的事。使用read和write,我们拷贝内核缓冲的数据到应用的缓冲(read),然后拷贝应用缓冲的数据到内核的缓冲(write)。使用mmap和memcpy,我们直接从一个映射到我们地址空间的内核缓冲里拷贝数据到另一个映射到我们内存空间的内核缓冲里。
当拷贝一个普通文件时,内存映射I/O更快。有些限制。我们不能使用它在特定设备(比如网络设备或终端设备)之间拷贝,而且我们必须小心如果底下文件可能在我们映射它之后改变了。尽管如此,一些应用可以从内存映射I/O得到好处,因为它经常简化算法,我们操作内存而不是读取一个文件。一个从内存映射I/O得到好处的例子是一个指向一个位映射显示的框架缓冲设备的操作。
我们在15.9节回到内存映射I/O,展示一个它如何用来在相关进程里提供共享内存的例子。
14.10 总结
在这章,我们描述过许多高级I/O函数,多数在后面各章里的例子里使用。
1、非阻塞I/O--执行一个I/O操作而不让它阻塞;
2、记录锁(我们在20章里的数据库函数库的例子里看到更多细节);
3、系统V STREAMS(我们将需要它在17章了解基于STREAMS的管道、传递文件描述符、和系统V的客户端-服务器连接);
4、I/O复用--select和poll函数(我们在后面很多例子里使用);
5、readv和writev函数(也在后面很多例子里使用);
6、内存映射I/O(mmap)。
15.1 引言
在第8章,我们描述过进程控制原始例程,并看过如何调用多个进程。但是这些进程交换信息的唯一方法是通过在fork或exec时传递打开的文件或通过文件系统。我们现在将描述其它让进程与对方通信的技术:IPC,与就是进程间通信。
在过去,UNIX系统IPC是各种方法的大杂烩,其中几乎没有可以在所有UNIX系统实现上移植的。通过POSX和The Open Group(X/Open的前身)标准化的努力,状态得到改善,但是区别仍然存在。下表总结了由本文讨论的四个实现IPC的各种形式,
IPC 类型 | SUS | Free BSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
---|---|---|---|---|---|
半双工(half-duplex)管道 | * | (全双工) | * | * | (全双工) |
FIFO | * | * | * | * | * |
全双工管道 | 允许 | *、UDS | 可选、UDS | UDS | *、UDS |
命名全双工管道 | XSI 可选项 | UDS | 可选、UDS | UDS | *、UDS |
消息队列 | XSI | * | * | * | |
信号量 | XSI | * | * | * | * |
共享内存 | XSI | * | * | * | * |
套接字 | * | * | * | * | * |
STREAMS | XSI | 可选 | * |
在上表,我们在基于功能被支持时显示一个着重号。对于全双工管道,如果特性通过UNIX域套接字(17.3节)支持, 我们在列里展示“UDS”。一些实现用管道和UNIX域套接字支持这个特性,所以这些项同时有着重号和“UDS”。
正如在14.4节里提过的,STREAMS的支持在SUS里可选的。命名全双工管道作为挂载的基于STREAMS的管道被支持,也是SUS的可选项。在Linux上,STREAMS的支持在一个独立可选的名为“Lis”(Linux STREAMS)的包里支持。我们在平台通过一个可选包提供特性的支持的地方显示“可选”--通常默认是不安装的。
上表的前7种形式的IPC通常局限于同一主机上的进程间的IPC。最后两行--套接字和STREAMS--是仅有的两个支持不同主机上进程间的IPC。
我们把IPC的讨论分为三章。在本章,我们检查经典的IPC:管道、FIFO、消息队列、信号量、和共享内存。在下一章,我们看下使用套接字机制的网络IPC。在17章,我们看下IPC的一些高级特性。
15.2 管道(Pipes)
管道是UNIX系统IPC的最早的形式,并被所有UNIX系统提供。管道有两个限制。
1、历史上,它们是半双工的(也就是说,数据只往一个方向流)。一些系统现在提供全双工管道,但是为了最大的可移植性,我们不应该假定这种情况。
2、管道只能用在有共同祖先的进程之间。通常,一个管道被一个进程创建,这个进程调用fork,管道在父进程和子进程之间使用。
我们将看到FIFO(15.5节)处理了第二个限制,UNIX域套接字(17.3节)和命名基于STREAMS的管道(17.2.2节)处理了两个限制。
尽管这些限制,半双工管道仍是最普遍使用的IPC的形式。每次你在一个管道输入一个命令序列让外壳来执行时,外壳为每个命令创建独立的进程并把标准输出和后一个使用管道的标准输入链到一起。
一个管道通过调用pipe函数被创建。
#include
int pipe(int filedes[2]);
成功返回0,错误返回-1。
两个文件描述符通过filedes参数被返回:filedes[0]被打开来读,filedes[1]被打开来写。filedes[1]的输出是filedes[0]的输入。
在4.3BSD、4.4BSD和Mac OS X 10.3里管道使用UNIX域套接字实现。即使UNIX域套接字默认是全双工,然而这些操作系统阻碍了管道使用的套接字,以便它们只以半双工模式操作。
POISIX.1允许一个实现支持全双工管道。对于这些实现,filedes[0]和filedes[1]都被打开来读和写。
一个半双工管道可以用两个角度来看,一个是管道两端在单个进程里相连接。另一个角度是强调管道的数据通过内核流动。
fstat函数(4.2节)为一个管道某端的文件描述符返回一个FIFO的文件类型。我们可以用S_ISFIFO宏测试一个管道。
POSIX.1指出stat结构体的st_size成员为于管道是无定义的。但是当fstat函数应用于管道的读端的文件描述符时,许多系统在st_size里存储管道里可读的字节。尽管如此,这不是可移植的。
单个进程里的管道是没什么用的。通常,调用pipe的进程接着会调用fork,创建一个父进程到子进程或相反方向的IPC渠道。下图显示了这个场景:
在fork之后发生了什么取决于我们想要数据流的哪个方向。对于一个从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),而子进程关闭写端(fd[1])。下图显示了最终的描述符排列:
当一个进程的某端被关闭,以下的两条规则被应用:
1、如果我们从一个写端被关闭的管道里read,那么read在所有数据被读之后返回0来指明一个文件末尾。(技术上,我们应该说这个文件末尾直到没有更多的这个管道的写者时才产生。复制一个管道描述符以便多个进程打开这个管道来写是可能的。尽管如此,通常一个管道有单个读者和单个写者。当我们在下节讨论FIFO时,我们将看到单个FIFO经常有多个写者。)
2、如果我们向一个读端被关闭的管道里write,那么信号SIGPIPE被产生。如果忽略这个信号或捕获它并从信号处理机返回,那么write返回-1,errno设置为EPIPE。
当我们向一个管道(或FIFO)写时,常量PIPE_BUF指明内核的管道缓冲尺寸。一个PIPE_BUF字节或更少的write将不会和其它进程对相同管道(或FIFO)的write交叉。但是如果多个进程正向一个管道(或FIFO)写,而我们write与PIPE_BUF字节更多,数据可能会和其它写者的数据交叉。我们可以用pathconf或fpathconf来决定PIPE_BUF的值。
下面的代码展示了产生一个父子进程间的管道,并向管道发送数据。
考虑一个显示它创建的一些输出的程序,一次一页。我们想调用用户最感兴趣的换页器,而不是重新发明一些UNIX系统工具完成的页码。为了阻止向一个临时文件写入所有的数据并调用system来显示这个文件,我们想把输出直接通过管道传到换页器。为了达到这个目的,我们创建一个管道,fork一个子进程,设置子进程的标准入到管道的读端,并exec用户的换页程序。下面的代码展示了如何做这件事。(例子接受命令行参数来指定要显示的文件的名字。一个这样类型的程序经常已经在内存里有了要显示在终端的数据了。)
当我们复制一个描述符到另一个上时(子进程里fd[0]到标准输入),我们必须小心描述符不是已经有所需的值。如果描述符已经有所需的值而我们调用dup2并close,那么描述符的单个拷贝会被关闭。(回想当两个参数相同时的dup2操作,3.12节)。在这个程序里,如果标准输入没有被一个外壳打开,开头的fopen会使用描述符0,最小的未使用的描述符,所以fd[0]绝对不应该等于标准输入。尽管如此,每当我们调用dup2和close来复制一个描述符到另一个上时,我们将总是先比较描述符,作为一个健壮编程的态度。
注意现在我们尝试使用环境变量PAGER来得到用户分页器程序。如果这不工作,我们使用默认的。这是环境变量的一个普遍用法。
回想8.9节的五个函数TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT和WAIT_CHILD。在第10章,我们展示了使用信号的一个实现。下面的代码展示一个使用管道的实现。
注意每个管道有一个额外的读者,这没有关系。也就是说,除了子进程从pfd1[0]读,父进程也将第一个管道的这一端打开来读。这不会影响我们,因为父进程不会从这个管道尝试去读。
15.3 popen函数和pclose函数
因为一个普遍的操作是为另一个进程创建一个管道,或者读它的输出或向它发送输入,所以标准I/O库历史上提供了popen和pclose函数。这两个函数处理我们自己一直在做的脏活:创建一个管道、fork一个子进程、关闭管道无用的端,执行一个外壳来运行这个命令,等待命令终止。
#include
FILE *popen(const char *cmdstring, const char *type);
成功返回文件指针,错误返回NULL。
int pclose(FILE *fp);
返回cmdstring的终止状态,错误返回-1。
函数popen执行一个fork和exec来执行cmdstring,并返回一个标准I/O文件指针。如果type是“r”,那么文件指针被连接到cmdstring的标准输入。
如果type是“w”,那么文件指针被连接到cmdstring的标准输入。
一种记住popen的最后一个参数的方法是:像fopen一样,返回的文件指针在“r”的type时是可读的,或在“w”的type时是可写的。
pclose函数关闭标准I/O流,等待命令的终止,返回外壳的终止状态。(我们在8.6节描述过终止状态。system函数,8.13节,也返回终止状态。)如果外壳不能被执行,pclose返回的状态就好像外壳执行了一个exit(127)。
cmdstring被Bourne shell,如
sh -c cmdstring
这意味着外壳展开了cmdstring里的任何特殊字符。例如,这允许我们说:fp = popen("ls *.c", "r");或fp = popen("cmd 2>&1", "r");
让我们用popen重新实现15.2节的第二个程序。
外壳命令${PAGER:-more}说如果这个外壳变量PAGER被定义且非空则使用它,否则使用字符串more。
下面的代码展示了popen和pclose的我们的版本。
调用pipe和fork然后为每个进程复制恰当的描述符和我们在本章前面做的事件相似。
POSIX.1要求popen关闭任何在子进程里通过上次popen调用打开的流。为了做到这个,我们遍历子进程里的childpid数组,关掉任何仍然打开的描述符。
如果pclose调用者已为SIGCHLD设立一个信号处理机会发生什么?pclose里的waitpid调用会返回EINTR的错误。因为调用者被允许捕获这个信号(或任何可能中断waitpid的其它信号),所以我们简单地再次调用waitpid,如果它被一个捕获的信号中断。
注意如果应用调用waitpid并获得popen创建的子进程的退出状态,那么我们将在应用调用pclose的时候调用waitpid,发现子进程不再存在,返回-1并设置errno为ECHILD。这是POSIX.1在这种情况所要求的行为。
pclose的早期版本返回一个EINTR的错误,如果一个信号中断了wait。同样,一些早期版本的plose在wait期间阻塞或忽略信号SIGINT、SIGQUIT和SIGHUP。这不被POSIX.1允许。
注意popen决不应该被一个设置用户ID或设置组ID程序调用。当它执行命令时,popen做等价于execl("/bin/sh", "sh", "-c", command, NULL);的事,它用从调用者继承下来的环境执行外壳和command。一个恶意用户可以操作环境,以便外壳执行不被期望的命令,使用从设置ID文件模式得到的权限。
popen特别适合的事是执行简单的过滤器来转换运行的命令的输入或输出。这是一个命令想要建立自己的管道的情况。
考虑一个向标准输出写一个提示并从标准输入读一任的应用。使用popen,我们可以在应用和它的输入之间插入一个程序来转换输入。这些进程的排列为:父进程创建一个子进程运行这个过滤器,并创建管道,使过滤器的标准输出变为管道的写端。父进程向用户终端输出提示,用户通过终端向过滤器输入,而过滤器的输出通过管道,被父进程读取。
例如,这个转换可以是路径名扩展,或者提供一个历史机制(记住前一个输入的命令)。
下面的代码展示了一个简单的过滤器来证明这个操作。这个过滤拷贝标准输入到标准输出,把任何大写字符轮换为小写。在写一个换行符我们小心地ffush标准输出的原因在下节谈到协进程时讨论。
15.4 协进程(Coprocesses)
一个UNIX系统过滤器是一个从标准输入读并写到标准输出的程序。过滤器通常在外壳管道里线性连接。一个过滤器变为一个协进程,当同一个的程序产生过滤器的输入并读取过滤器的输出。
Korn外壳提供了协进程。Bourne、Bourne-again和C外壳都没有提供一种把进程作为协进程连接在一起的方法。一个协进程通常从一个外壳运行到后台,它的标准输入和标准输出使用一个管道被连接到另一个程序。尽管初始化一个协进程并连接它的输入输出到另一个程序所需的外壳语法相当绕,但是协进程在C程序里也有用。
popen给了我们一个写到另一个进程的标准输入或从它的标准输出读的单向管道,而使用协进程,我们有了到另一个进程的双向管道:一个向它的标准输入写,一个从它的标准输出读。我们想向它的标准输入写,让让操作数据,然后从它的标准输出读。
让我们在一个例子里看下协进程。进程创建两个管道:一个是协进程的标准输入,而另一个是协进程的标准输出。
下面的代码是一个简单的协进程,它从标准输入读取两个数,计算它们的和,并把和写到标准输出。(协进程通常做比我们这里演示的更有趣的工作。这个例子是公认地捏造的,以便我们可以学习连接进程所需的探索。)
下面的代码调用上面的协进程,在从标准输入读两个数后。协进程的值被写到它的标准输出。
如果我们在之前的程序里调用这个新的协进程,那么它不再工作。问题是默认的标准I/O缓冲。当我们调用上面的代码时,第一个标准输入上的fgets导致标准I/O库分配一个缓冲并选择缓冲的类型。因为标准输入是一个管道,所以标准I/O库默认为完全缓冲的。相同的事情在标准输出里也会发生。当add2被阻塞在从它的标准输入里读时,协进程正被阻塞在从管道的读。我们有一个死锁。
这里,我们有对正被运行的协进程的控制。我们可以改变上面的代码,在while循环前加入以下几行:
如果我们不能修改我们为其建立管道输出的程序,那么需要其它技术。例如,如果我们使用awk作为我们程序的一个协进程(而不是filter_add_two_numbers程序),下面的命令不会工作:
#! /usr/bin/awk -f
{ print $1 + $2 }
不工作的原因再次是标准I/O缓冲。但是在这种情况睛, 我们不能改变awk工作的方式(除非我们有它的源码)。我们不能以任何方式修改awk的可执行程序来改变标准I/O缓冲被处理的方式。
这个通用问题的解决方案是让被调用的协进程(这个情况下是awk)认为它的标准输入和标准输出被连接到一个终端。那导致协进程进程里的标准I/O例程来行缓冲这两个I/O流,和我们之前显示地调用setvbuf相似。我们在19章使用伪终端来做这个。
15.5 FIFO
FIFO通常被称为命名管道。管道只能在有共同祖先创建管道时的相关进程间使用。(一个例外是挂载的基于STREAM的管道,17.2.2节。)然而,有了FIFO,不相关的进程也可以交换数据。
我们在第4章看到FIFO是一个文件类型。stat结构体(4.2节)的st_mode成员的某个编码指明一个文件是FIFO。我们可以用S_ISFIFO宏来测试这个。
创建一个FIFO和创建一个文件相似。事实上,FIFO的路径名存在于文件系统里的。
#include
int mkfifo(const char *pathname, mode_t mode);
成功返回0,错误返回-1。
mkfifo函数的mode参数的规定和open函数(3.3节)相同。新FIFO的用户和组的所属关系的规则和4.6节描述的一样。
一旦我们使用了mkfifo来创建一个FIFO,我们使用open来打开它。事实上,普通的文件I/O函数(close、read、write、unlink等)都能工作在FIFO上。
应用可以用mknod函数创建FIFO。因为POSIX.1最开始并没有包含mknod,所以mkfifo为POSIX.1特意发明。mknod函数现在包含在XSI扩展里。在多数系统上,mkfifo调用mknod来创建FIFO。
POSIX.1也包含mkfifo命令的支持。本文四个平台都提供了这个命令。这允许一个FIFO使用一个外壳命令被创建,然后用普通外壳I/O重定向访问。
当我们open一个FIFO时,非阻塞标志(O_NONBLOCK)影响了发生的事情。
1、在通常情况下(O_NONBLOCK没有被指定),一个只读的open阻塞,直到一些其它进程打开FIFO来写。相似的,只写的open阻塞,直到一些其它进程打开FIFO来读。
2、如果O_NONBLOCK被指定,一个只读的open立即返回。但是只写的open返回-1,errno设为ENXIO,如果没有进程打开FIFO来读。
和一个管道一样,如果我们write到一个没有进程打开来读的FIFO,那么信号SIGPIPE被产生。当FIFO的最后写者关闭了FIFO,一个文件末尾为FIFO的读者产生。
对一个给定的FIFO有多个写者是普遍的。这意味着我们必须担心原子写,如果我们不想多个进程的写交叉。(我们将在17.2.2节看到解决这个问题的一种方法。)和管道一样,常量PIPE_BUF指定了可以被原子写到FIFO的最大数据量。
FIFO有两种用法:
1、FIFO被外壳命令用来传递数据,从一个外壳管道到另一个,而不需要创建中间的临时文件。
2、FIFO被用来在客户-服务器应用里作为集结点来在客户端和服务器端之间传送数据。
我们为每种使用提供一个例子。
FIFO可以被用来在一个外壳命令序列里复制一个输出流。这避免了向一个临时磁盘文件写数据(和使用管道来避免中间磁盘文件是相似的)。但是管道只能被用来在进程间进程线性连接,而FIFO有一个名字,所以它可以用在非线性连接上。
考虑一个需要处理一个过滤好的输入流两次的过程。程序prog1读取输入文件,把输出同时发送到程序prog2和prog3。
使用FIFO和UNIX系统tee,我们可以完成这个过程,而不需要一个临时文件。(tee程序把它的标准输入同时拷贝到它的标准输出和在它命令行命名的文件里。)
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2
我们创建FIFO然后在后台启动prog3,从FIFO读取。我们然后启动prog1并使用tee来把它的输入同时发送给FIFO和prog2。(在我们Linux3.0上,这种方式有时不工作,因为FIFO的读者有时收不到写者发出的数据,而有时也能收到。)
FIFO的另一个使用是在客户端和服务器端之间发送数据。如果我们有一个服务器,它被许多客户联系,每个客户可以把它的请求写到一个由服务器创建的被熟知的FIFO。(“被熟知的”的意思是FIFO的路径名被所有需要和服务器联系的客户端所知道。)服务器创建一个FIFO并从中读取读取,多个客户端向这个FIFO写入请求。因为FIFO有多个写者,客户端发送给服务器的请求应该比PIPE_BUF字节少。这避免了任何客户write的交叉。
使用FIFO作为这种类型的客户-服务器通信的问题是如何从服务器发回回复给每个子进程。单个FIFO不能被使用,因为客户端不会知道何时去读它们的响应,与其它客户端的响应相对。一种解决方案是每个客户端随着请求发送它的进程ID。服务器然后为每个客户端创建一个唯一的FIFO,使用基于客户端进程ID的路径名。例如,服务器可以创建一个FIFO,名字为/tmp/serv1.XXXX,这里XXXX被客户端的进程ID代替。
这种方式可以工作,尽管服务器不可能知道一个客户端是否崩溃。这导致客户相关的FIFO被留在文件系统里。服务器也必须捕获SIGPIPE,因为发送一个请求的客户端可能在读响应之前终止,导致客户端相关的FIFO有一个写者(服务器)却没有读者。我们将看到更智能的解决这个问题的谅地,当在17.2.2节讨论挂载的基于STREAMS的管道时。
有了上面的解决方案,如果服务器在每次客户端数从1变为0时以只读方式打开它的被熟知的FIFO(因为只从FIFO里read),那么服务器为在FIFO上read到一个文件末尾。为了避免服务器必须处理这种情况,一个普遍的诡计只是让服务器以读写方式open它的被熟知的FIFO。
15.6 XSI IPC
三种我们称为XSI IPC的IPC类型--消息队列、信号量、和共享内存--有许多相似点。在本节,我们覆盖这些相似的特性;在之后的几节, 我们看下这三个IPC类型的每个所相关的函数。
XSI IPC函数紧密地基于系统V IPC函数。这三种类型的IPC起源于上世纪70年代,在一个UNIX内部AT&T版本的UNIX系统里,称为“Columbus UNIX”。这些IPC特性稍后被加入到系统V里。它们通常因为创建它们自己的命名空间而不是文件系统而被批评。
回想15.1节,消息队列、信号量、和共享内存被作为SUS里的XSI扩展定义。
15.6.1 标识符和关键字(Identifiers and Keys)
每个IPC结构体(消息队列、信号量、或共享内存段)在内核里被一个非负整型标识符引用。为了向一个消息队列发送或从它那里得到一个消息,我们所有需要知道的只是队列的标识符。不像文件描述符,IPC描述符不是小整数。事实上,当一个给定的IPC结构体被创建然后被删除时,和那个结构体相关的标识符持续增加,直到达到了一个整型的最大正值,然后回到0。
标识符是一个IPC对象的内部名字。合作进程需要一个外部命名机制来集结地使用相同的IPC对象。为了这个目的,一个IPC对象被关联到一个关键字,它作为一个外部名字。
每当一个IPC结构体正被创建时(通过调用msgget、semget、或shmget),一个关键字必须被指定。这个关键字的数据类型是原始系统数据类型key_t,它经常被定义为一个长整型,在头文件
一个客户端和服务器有多种方式集结在相同的IPC结构体。
1、服务器可以创建一个IPC结构体,通过指定一个IPC_PRIVATE的关键字,并把返回的标识符存储在某地(比如一个文件),以便客户得到。关键字IPC_PRIVATE保证了服务器创建了一个新的IPC结构体。这个技术的缺点是文件系统操作被服务器需要,以在一个文件写入整型标识符,以便让客户端稍后得到这个标识符。IPC_PREVIATE关键字也在父子关系里使用。父进程创建一个新的IPC结构体,指定IPC_PRIVATE,最后的标识符在fork之后然后对子进程可用。子进程可以传递标识符给一个新程序,作为某个exec函数的一个参数。
2、客户端和服务器可以对一个关键字达成共识,例如通过以一个通用头来定义关键字。服务器然后创建一个新的指定这个关键字的IPC结构体。这种方式的问题是关键字可能已经被关联到一个IPC结构体上,在这种情况下get函数(msgget、semget、或shmget)返回一个错误。服务器必须处理这个错误,删除已存在的IPC结构体,并再次尝试创建它。
3、客户端和服务器可以对一个路径名和工程ID上达成共识(工程ID是0到255的一个字符值),并调用函数ftok来把这两个值转换到一个关键字里。这个关键字然后在步骤2里使用。ftok提供的唯一服务是从一个路径名和工程ID产生一个关键字的一个方法。
#include成功返回关键字,失败返回(key_t)-1。
path参数必须指向一个已有的文件。在产生关键字时只有id的低8位被使用。
被ftok创建的关键字通常由对应于给定路径名的stat结构体(4.2节)里的st_dev和st_ino域的部分组成,并把它们和工作ID结合。如果两个路径名指向两个不同的文件,那么ftok通常为两个路径名返回两个不同的关键字。然而,因为i-node号和关键字经常都被存储在长整型里,所以创建一个关键字里可能有信息丢失。这意味着两个指向不同文件的不同的路径名可以产生相同的关键字,如果相同的工程ID被使用。
这三个get函数(msgget、semget、和shmget)都有两个相似的参数:一个关键字和一个整型标志。一个新的IPC结构体被创建(通常被一个服务器),如果关键字是IPC_PRIVATE,或者当前没有和特定类型的一个IPC结构体相关联且标志的IPC_CREAT位被指定。为了引用一个已有的队列(通常由一个客户端完成),关键字必须和当队列被创建时所指定的关键字相同,而且IPC_CREAT必须不能被指定。
注意决不可能指定IPC_PRIVATE来指定一个已有的队列,因为这个特殊的关键字值总是创建一个新的队列。为了引用一个用IPC_PRIVATE的关键字创建的已存在的队列,我们必须知道相关联的标识符,然后在其它IPC调用里使用那个标识符(比如msgsnd和msgrcv),绕开get函数。
如果我们想创建一个新的IPC结构体,以确保我们不会用相同的标识符引用到一个已经存在的,那么我们必须同时设置IPC_CREAT和IPC_EXCL位来指定一个标志。这样做导致一个EEXIST的错误返回,如果IPC结构体已经存在。(这和指定O_CREAT和O_EXCL标志的open相似。)
15.6.2 权限结构体(Permission Structure)
XSI IPC把一个ipc_perm结构体关联到每个IPC结构体里。这个结构体定义了权限和属主,并至少包含以下的成员:
struct ipc_perm {
uid_t uid; /* owner's effective user id */
gid_t gid; /* owner's effective group id */
uid_t cuid; /* creator's effective user id */
gid_t cgid; /* creator's effective group id */
mode_t mode; /* access modes */
...
};
每个实现包含补充的成员。在你的系统上看
所有的域在IPC结构体被创建时被初始化。在稍后某个时刻,我们可以修改uid、gid和mode域,通常调用msgctl、semctl或shmctl。为了改变这些值,调用进程必须是IPC结构体的创建者或者超级用户。改变这些域和为一个文件调用chown或chmod相似。
mode域里的值和我们在第4章看到的值相似,但是没有对应的任何IPC结构体的执行权限。同样,消息队列和共享内存使用术语读和写,但是信号量使用术语读和改变(alter)。下表展示了每种形式的IPC的6个权限。
权限 | 位 |
---|---|
用户读 | 0400 |
用户写(改变) | 0200 |
组读 | 0040 |
组写(改变) | 0020 |
其他人读 | 0004 |
其他人写(改变) | 0002 |
15.6.3 配置限量(Configuratoin Limits)
所有三种形式的XSI IPC都有我们可能碰到的内置的限量。这些限量里多数可以通过重新配置内核来改变。我们在描述这三种形式的IPC中的每个时描述这些限量。
每个平台提供它自己的方式来报告和修改一个特定的限量。FreeBSD、Linux和Mac提供sysctl命令来查看和修改内核配置参数。Solaris上,对内核配置参数的改变由修改文件/etc/system并重启来完成。
在Linux上,你可以显示IPC相关的限量,通过运行ipcs -l。在FreeBSD上,等价的命令是ipcs -T。在Solaris上,你可以发现可调的参数,通过运行sysdef -i。
15.6.4 优点和缺点(Advantages and Disadvantages)
XSI IPC的一个基本的问题是IPC结构体是系统范围的且没有一个引用计数。例如,如果我们创建一个消息列队,在上面放置一些消息,然后终止,那么消息队列和它的内容不会被删除。它们停留在系统里,直到被确切地读或删除,通过一些进程调用msgrcv或msgctl、或者执行ipcrm命令、或者系统重启。把这个和管道比较,后者在最后一个引用它的进程终止时会被完全删除。对于FIFO,尽管名字保存在文件系统,直到显式地删除,但是任何在FIFO里的数据在最后一个引用这个FIFO的进程终止里会被删除。
XSI IPC的另一个问题是这些IPC结构体不能在文件系统里通过名字知道。我们不能访问它们或修改它们的属性,使用第3、4章的函数。几乎有一打新的系统调用(msgget、semop、shmat等)被加入到内核来支持这些IPC对象。我们不能用ls命令看到这些IPC对象,不能用rm命令删除它们,也不能用chmod命令来改变它们的权限。相反,两个新命令--ipcs和ipcrm--被加入。
因为这些形式的IPC不使用文件描述符,所以我们不能在它们上使用复用I/O函数(select和poll)。这导致同时使用多个这样的IPC结构体或通过文件或设备I/O来使用这些IPC结构体更加困难。例如,我们不能让一个服务器等待一个被放置到两个消息队列中的某个的消息,而不通过一些忙等待形式的循环。
一个使用系统V IPC建立的一个事务处理系统的总览被Andrade、Carges和Kovach[1989]给出。他们声明系统V IPC使用的命名空间(标识符)是一个优点,不是一个如我们之前所说的问题,因为使用标识符允许一个进程使用单个函数调用(msgsnd)来向一个消息队列发送消息,而一些形式的IPC通常需要一个open、write和close。这个争论是不恰当的。客户端仍然需要得到服务器的队列的标识符,以某种方式,来避免使用一个关键字和调用msgget。分配给一个特定队列的标识符取决于当队列被创建时有多少其它消息队列,以及内核里分配给新队列的表自从内核重启后被使用了多少次。这是一个动态的值,不能被猜测或存储在一个头文件里。如我们在15.6.1节里提到的,一个服务至少要把分配的队列标识符写到一个文件以便客户来读。
另一个这些作者为消息队列所列出的优点是它们很可靠、流控制的、面向记录的、以及可以以不是先进先出的其它方式被处理。正如我们在14.4节里看到的,STREAMS机制也处理所有这些属性,尽管需要在发送数据给一个流之前需要一个open,在我们完成时需要一个close。下表比较了这些各种形式的IPC的一些特性。
IPC类型 | 无连接的? | 可靠的? | 流控制? | 记录? | 消息类型或优先级? |
---|---|---|---|---|---|
消息队列 | 否 | 是 | 是 | 是 | 是 |
STREAMS | 否 | 是 | 是 | 是 | 是 |
UNIX域流套接字 | 否 | 是 | 是 | 否 | 否 |
UNIX哉数据报套接字 | 是 | 是 | 否 | 是 | 否 |
FIFO(非STREAMS) | 否 | 是 | 是 | 否 | 否 |
(我们在16章描述流和数据报套接字。在17.3节描述UNIX域套接字。)“无连接”的意思是不必先调用某种形式的打开函数来发送一个消息的能力。如之前描述的,我们不把消息队列视为无连接的,因为需要一些技术来得到一个队列的标识符。因为所有这些形式的IPC被限制为单个主机,所以所有的都是可靠的。当消息通过一个网络发送时,消息被丢失是有可能的。“流控制”表示发送者被催眠,如果系统资源短缺(buffer)或接收者没有接受任何更多的消息。当流控制条件消退时,发送者应该自动被唤醒。
一个我们没有在上表展示的特性是IPC设施是否可以为每个客户自动创建到服务器的唯一的连接。我们将在17章看到STREAMS和UNIX流套接字提供了这种能力。
下面三节深入描述了这三种形式的XSI IPC的每一个。
15.7 消息队列
一个消息队列是一个消息的链表,存储在内核里,并由一个消息队列标识符标识。我们将只称消息队列为一个队列,称它的标识符为一个队列ID。
SUS包含了一个替代的IPC消息队列的实现,在实时扩展的消息传递选项里。我们不在本文包含实现扩展。
一个新的队列被创建或一个已有队列被打开,通过msgget。新的消息被加入到一个队列的尾部,通过msgsnd。每个消息有一个正的长整型类型域,一个非负长度,和真实数据字节(对应于长度),这些所有在消息加入到一个队列时由msgsnd指定。消息通过msgrcv从一个队列获取。我们不必以先进先出的方式得到消息。相反,我们可以基于它们的类型域来获取消息。
每个队列有以下的msqid_ds结构体与它相关:
struct msqid_ds {
struct ipc_perm msg_perm; /* see Section 15.6.2 */
msgqnum_t msg_qnum; /* # of messages on queue */
msglen_t msg_qbytes; /* max # of bytes on queue */
pid_t msg_lspid; /* pid of last msgsnd() */
pid_t msg_lrpid; /* pid of last msgrcv() */
time_t msg_stime; /* last-msgsnd() time */
time_t msg_rtime; /* last-msgrcv() time */
time_t msg_ctime; /* last-change time */
...
};
这个结构体定义了队列的当前状态。展示的成员是由SUS定义的。实现包含不被标准覆盖的补充域。
下表列出了影响消息队列的系统限量。我们在平台不支持这个特性的地方显示“不支持”。每当一个限量从其它限量继承时我们显示“继承”。例如,Linux系统里的消息数量的最大值是基于队列的最大数量和队列上允许的最大数据量。如果最小消息尺寸为1字节,那么它将限制系统范围的消息数为最大队列数乘以一个队列的最大尺寸。给定下表的限量,Linux在默认配置里有262144消息的上限。(即使一个消息可以包含0字节的数据,但是Linux视它为1字节,来限制排队消息的数量。)
描述 | 典型值 | |||
FreeBSD | Linux | Mac | Solaris | |
我们可以发送的最大消息的字节尺寸 | 16384 | 8192 | 不支持 | 2048 |
一个特定队列的最大字节尺寸(也就是说,队列上所有消息的和。) | 2048 | 16384 | 不支持 | 4096 |
系统范围的消息队列的最大数量。 | 40 | 16 | 不支持 | 50 |
系统范围的消息最大数量。 | 40 | 继承 | 不支持 | 40 |
回想15.1节,Mac OS X 10.3不支持XSI消息队列。因为Mac基于一部分的FreeBSD,而FreBSD支持消息队列,所以Mac也有可能支持它们。事实上,一个好的因特网搜索引擎会提供Mac的XSI消息队列的一个第三方端口的指针。
通常第一个被调用的函数是msgget,以打开一个存在的队列或创建一个新的队列。
#include
int msgget (key_t key, int flag);
成功返回消息队列ID。错误返回-1。
在15.6.1节,我们描述了把关键字转换为一个描述符的规则并讨论了是一个新的队列被创建还是一个存在的队列被引用。当一个新的队列被创建时,以下的msqid_ds结构体的成员被初始化:
1、ipc_perm结构体被初始化,如15.6.2节描述的。这个结构体的mode成员被设置为标志的对应的权限位。这些权限由15.6.2节的表里的值指定。
2、msg_qnum、msg_lspid、msg_lrpid、msg_stime、和msg_rtime都被设置为0。
3、msg_ctime被设为当前时间。
4、msg_qbytes被设置为系统限量。
成功的话,msgget返回非负的队列ID。这个值然后和其它三个消息队列函数一起使用。
msgctl函数在一个队列上执行各种操作。这个函数和相关的信号量和共享内存的函数(semctl和shmctl)都是XSI IPC的类ioctl函数(也就是说,垃圾桶(garbage-can)函数)。
#include
int msgctl (int msqid, int cmd, struct msqid_ds *buf);
成功返回0,错误返回-1。
cmd参数指定要在由msqid指定的队列上执行的命令。
IPC_STAT:得到这个队列的msqid_ds结构体,把它存储在buf指向的结构体里。
IPC_SET:把buf指向的结构体的以下域拷贝到和这个队列相关的msqid_ds结构体里:msg_perm.uid、msg_perm.gid、msg_perm.mode、和msg_qbytes。这个命令只能被一个有效用户ID等于msg_perm.cuid或msg_perm.uid或有超级用户权限的进程执行。只有超级用户可以增加msg_qbytes的值。
IPC_RMID:从系统中删除这个消息队列和任何仍在这个队列上的数据。这个删除是立即的。任何仍在使用这个消息队列的进程在下次尝试在这个队列上操作时会得到一个EIDRM错误。这个命令只能被有效用户ID等于msg_perm.cuid或msg_perm.uid或有超级用户权限的进程执行。
我们将看到这三个命令(IPC_STAT、IPC_SET和IPC_RMID)也为信号量和共享内存提供。
数据被放到一个消息队列,通过调用msgsnd。
#include
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
成功返回0,错误返回-1。
正如我们之前描述的,每个消息由一个正的长整型类型域、一个非负长度(nbytes)、和真实数据字节(对应于长度)组成。消息总是被放到队列末尾。
ptr参数指向一个包含正整型消息类型的长整型,后面紧接着消息数据。(如果nbytes为0则没有消息数据。)如果我们发送的最大的消息是152字节,那么我们可以定义如下结构体:
struct mymesg {
long mtype; /* positive message type */
char mtext[512]; /* message data, of length nbytes */
};
ptr参数然后是一个指向mymesg结构体的指针。消息类型可以被接收者用来以不是先进先出的顺序得到消息。
一些平台同时支持32位和64位环境。这影响了长整型和指针的尺寸。例如在64位SPARC系统上,Solaris允许32位和64位应用共存。如果一个32位应用是通过一个管道或套接字和一个64位应用交换数据,那么会出现问题,因为长整型尺寸在32位应用里是4字节,而在64位应用里是8字节。这表示一个32位应用将会期望mtext域在结构体开始后的8字节处开始。在这种情况里,64位应用的mtype域的一部分将作为32位应用的mtext域的一部分出现,32位应用的mtext域的前4个字节将被64位应用解释为mtype域的一部分。
然而这个问题不会在XSI消息队列里出现。Solaris实现32位版本的IPC系统调用,用和64位版本的IPC系统调用不同的进入点。这些系统调用知道如何处理32位应用和64位应用之间的通信,并特殊对待类型域以避免消息的数据部分的影响。唯一潜在的问题是信息丢失,当一个64位应用发送一个消息,使用8字节的类型域的值,它比32位应用的4字节域的所能容的值大。在这种情况下,32位应用会看到一个裁切的类型值。
一个IPC_NOWAIT的flag值可以被指定。这和文件I/O的非阻塞I/O标志(14.2节)相似。如果消息队列满了(队列上的消息数等于系统限量,或者队列上的字节数等于系统限量),那么指定IPC_NOWAIT导致msgsnd立即返回一个EAGAIN的错误。如果IPC_NOWAIT没有被指定,那么我们被阻塞,直到有消息的空间、队列在系统上被删除、或一个信号被捕获且信号处理机返回。在第二种情况里,EIDRM的错误被返回(“标识符被删除”,identifier removed);在最后一情况下,返回的错误是EINTR。
注意一个消息队列被删除是如何被笨拙地处理。因为没有引用计数随同每个消息队列被维护(如打开的文件有的那样),一个队列的删除在仍使用这个队列的0程在下次队列操作时简单地产生错误。信号量用相同的方式处理这个删除。相比之下,当一个文件被删除时,文件的内容不会被删除,直到为文件打开的最后的描述符被关闭。
当msgsnd成功返回时,和消息队列相关的msqid_ds结构体被更新,以指定执行这个调用的进程ID(msg_lspid)、调用被执行的时间(msg_stime)、和在队列里还有多了一个的消息(msg_qnum00)。
消息可以通过msgrcv从一个队列得到。
#include
ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);
成功返回消息的数据部分的尺寸,错误返回-1。
和msgsnd一样,ptr参数指向一个长整型(返回消息的消息类型存在这里)接着一个真实消息数据的一个数据缓冲。nbytes指定数据缓冲的尺寸。如果返回的消息比nbytes大而MSG_NOERROR位在flag里被设置,那么消息被裁切。(在这种情况下,没有消息告诉我们消息被裁切,以及剩余的消息被舍弃。)如果消息太大且这个flag值没有被指定,那么E2BIG的错误被返回(而且消息被留在队列上)。
type参数让我们指定想要哪个消息。
type == 0:返回队列上的第一个消息。
type > 0:队列上消息类型等于type的第一个消息被返回。
type < 0:队列上消息类型小于等于type的绝对值的最小值的消息被返回。
一个非零type被用来以不是先进先出的方式来得到消息。例如,type可以是一个优先级值,如果应用分配给消息分配优先级。这个域的另一个用法是包含客户的进程ID,如果单个消息队列被多个客户和单个服务器使用(只要一个进程ID适合放在一个长整型里)。
我们可以指定一个IPC_NOWAIT的flag值来让操作非阻塞,导致msgrcv返回-1并设置errno为ENOMSG,如果一个指定类型的消息不可用。如果IPC_NOWAIT没有被指定,那么操作阻塞,直到一个指定类型的消息可用、消息从系统上被删除( 返回-1并设置errno为EIRM)、或一个信号被捕获且信号处理机返回(导致msgrcv返回-1并设置errno为EINTR)。
当msgrcv成功时,内核更新和消息队列相关的msqid_ds结构体来指明调用者的进程ID(msg_lrpid)、调用的时间(msg_rtime)、和在队列上的更少一个的消息(msg_qnum)。
例子--消息队列和流管道的计时比较
如果我们需要客户和服务器间双向流动的数据,那么我们可以使用消息队列或全双工管道。(回想15.1节,全双工管道通过UNIX域套接字机制可用(17.3节),尽管一些平台通过pipe函数提供一个全双工管道机制。)
下表展示了Solaris上这三种技术的计时比较:消息队列、基于STREAMS的管道、和UNIX域套接字。这些测试由一个创建IPC渠道、调用fork、然后从父进程到子进程发送大约200M的数据的程序组成。数据使用100000个msgsnd的调用发送,为消息队使用长度为2000字节的消息。以及100000次的write调用,为基于STREAMS的管道和UNIX域套接字使用长度为2000字节的数据。计时都以秒为单位。
操作 | 用户 | 系统 | 时钟 |
---|---|---|---|
消息队列 | 0.57 | 3.63 | 4.22 |
STREAMS管道 | 0.50 | 3.21 | 3.71 |
UNIX域套接字 | 0.43 | 4.45 | 5.59 |
这些数值向我们展示了消息队列,最开始的实现是为了提供高于常速的IPC,已经不再比其它形式的IPC快很多了(事实上,基于STREAMS的管道比消息队列要快)。(当消息队列被实现时,另一个可用的唯一的IPC形式是半双工管道。)当我们考虑使用消息队列的问题时(15.6.4节),我们得到结论:不应该在新的应用里使用它们。
15.8 信号量(Semaphores)
一个信号量是一个和我们已经描述过的其它IPC不相似的IPC形式(管道、FIFO和消息队列)。一个信号量是一个计数器,用来提供多个进程对共享的数据对象的访问。
SUS包含了信号量接口的一个替代集,在它的实时扩展的信号量选项里。我们不在本文讨论这些接口。
为了得到一个共享的资源,一个进程需要做如下的事:
1、测试控制这个资源的信号量;
2、如果信号量的值为正,那么进程可以使用这个资源。在这种情况下,进程把信号量值减一,表明它已经用了一个单位的这个资源;
3、否则,如果信号量的值为0,那么进程进入睡眠,直到信号量值比0大。当进程醒来时,它返回到步骤1。
当一个进程完成了一个被一个信号量控制的共享资源的使用时,信号量值被增一。如果任何其它进程在睡眠,正等待这个信号量,那么它们被唤醒。
为了正确实现信号量,信号量值的测试和这个值的减少必须是一个原子操作。由于这个原因,信号量通常在内核里实现。
一个普遍形式的信号量被称为一个二元信号量(binary semaphore)。它控制单个资源,且它的值被初始化为1。尽管如此,通常一个信号量可以被初始化为任何正值,这个值指定多少单位的共享资源可用。
不幸的是,XSI信号量比这个更复杂。三个特性导致了这个不必要的复杂性。
1、一个信号量不是简单的单个非负值。相反,我们必须定义一个信号量为一个或多个信号量值的集合。当我们创建一个信号量时,我们指明集合进而的值的数量。
2、信号量的创建(semget)和它的初始化(semctl)无关。这是一个致命缺陷,因为我们不能自动创建一个新的信号量集并初始化这个集里所有的值。
3、因为所有形式的XSI IPC保持存在,甚至当没用进程在使用它们时,所以我们必须担心一个终止却没有释放已经被分配的信号量的程序。我们待会描述的undo特性被期望处理它。
内核为每个信号量集维护一个semid_ds结构体:
struct semid_ds {
struct ipc_perm sem_perm; /* see Section 15.6.2 */
unsigned short sem_nsems; /* # of semaphores in set */
time_t sem_otime; /* last-semop() time */
time_t sem_ctime; /* last-change time */
...
};
SUS定义了展示的域,但是实现可以在semid_ds结构体里定义补充的成员。
每个信号量都表示了一个匿名结构体,包含至少以下成员:
struct {
unsigned short semval; /* semaphore value, always >= 0 */
pid_t sempid; /* pid for last operation */
unsigned short semncnt; /* # processes awaiting semval>curval */
unsigned short semzcnt; /* # processes awaiting semval==0 */
};
下表列出了影响信号量集的系统限量(15.6.3节)。
描述 | 典型值 | |||
---|---|---|---|---|
FreeBSD | Linux | Mac | Solaris | |
任何信号量的最大值 | 32767 | 32767 | 32767 | 32767 |
任何信号量的adjust-on-exit值的最大值 | 16384 | 32767 | 16384 | 16384 |
系统范围的信号量集的最大数量 | 10 | 128 | 87381 | 10 |
系统范围的信号量的最大数量 | 60 | 32000 | 87381 | 60 |
每个信号量集的信号量的最大数量 | 60 | 250 | 87381 | 25 |
系统范围的unfo结构体的最大数量 | 30 | 32000 | 87381 | 30 |
每个undo结构体的undo项的最大数量 | 10 | 32 | 10 | 10 |
每个semop调用的操作的最大数量 | 100 | 32 | 100 | 10 |
第一个调用的函数是semget,来得到一个信号量ID。
#include
int semget(key_t key, int nsems, int flag);
成功返回信号量ID。错误返回-1。
在15.6.1节,我们描述了转换key到一个标识符的规则并讨论了一个新集合被创建还是一个已有集合被引用。当一个新的集合被创建时,semid_ds结构体的以下成员被初始化。
1、ipc_perm结构体被初始化,如15.6.2节描述的。这个结构体的mode成员被设置为对应的flag的权限位。这些权限由15.6.2节的表里的值指定。
2、sem_otime被设为0。
3、sem_ctime被设为当前时间。
4、sem_nsems被设为nsems。
集里的信号量的数量为nsems。如果一个新的集合被创建(典型地在服务器里),那么我们必须指定nsems。如果我们引用一个已有集合(客户),那么我们可以指定nsems为0。
semctl函数作为各种信号量操作的杂烩。
#include
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);
返回(如下)。
第4个参数是可选的,取决于请求的命令,如果有的话,它的类型是senum,一个各种命令相关参数的联合体:
union semun {
int val; /* for SETVAL */
struct semid_ds *buf; /* for IPC_START and IPC_SET */
unsigned short *array; /* for GETALL and SETALL */
};
注意可选参数是真实的联合体,而不是联合体的指针。
cmd参数指定以下10个命令的某一个,在semid指定的集合上执行。引用一个特定信号量值的五个命令使用semnum来指明这个集合的一个成员。semnum的值在0和nsems-1之间,包括两者。
IPC_STAT:等到这个集合的semid_ds结构体,把它存储到arg.buf所指的结构体里。
IPC_SET:设置sem_perm.uid、sem_perm.gid、和sem_perm.mode域,从这个集合的semid_ds结构体里的arg.buf指向的结构体。这个命令只可以被其用户用户ID等于sem_perm.cuid或sem_permuid,或有超级用户权限的进程执行。
IPC_RMID:从系统删除信号量集。这个删除是立即的。任何其它正在使用这个信号量的进程将在下次在这个信号量上尝试操作时得到一个EIDRM的错误。这个命令只可以被其用户用户ID等于sem_perm.cuid或sem_permuid,或有超级用户权限的进程执行。
GETVAL:返回成员semnum的semval值。
SETVAL:设置semnum成员的semval值。这个值由arg.val指定。
GETPID:返回成员semnum的sempid值。
GETNCNT:返回成员semnum的semncnt值。
GETZCNT:返回成员semmum的semzcnt值。
GETALL:得到集合里所有的信号量的值。这些值被存储在arg.array所指的数组里。
SETALL:设置集合里所有的信号量的值为arg.array所指的值。
对于除了GETALL之外的所有GET命令,函数返回对应的值。对于其它的命令,返回值是0。
函数semop自动执行在一个信号量集合上的一个操作数组。
#include
int semop(int semid, struct sembuf semoparray[], size_t nops);
成功返回0,错误返回-1。
semoparray参数是一个指向一个信号量操作数组的指针,由sembuf结构体表示:
struct sembuf {
unsigned short sem_num; /* member # in set (0, 1, ... nsems - 1) */
short sem_op; /* operation (negative, 0, or positive) */
short sem_flg; /* IPC_NOWAIT, SEM_UNDO */
};
nops参数指明数组里的操作(元素)的数量。
集合的每个成员上的操作由对应的sem_op值指定。这个值可以是负数、0、或正数。(在以下的讨论里,我们引用一个信号量的“undo”标志。这个标准对应于SEM_UNDO位,在对应的sem_flg成员里。)
1、最简单的情况是当sem_op为正的值。这情况对应于进程资源的返回。这个sem_op的值被加入到信号量的值。如果undo标志被指定,那么sem_op也从进程的信号量调整值里被减去。
2、如果sem_op为负数,那么我们想得到信号量控制的资源。如果信号量的值大于或等于sem_op的绝对值(资源可用),那么sem_op的绝对值从信号量的值里被减去。这保证了信号量的结果值大于或等于0。如果undo标志被指明,那么sem_op的绝对值也被加到进程的信号量的调整值里。
如果信号量的值比sem_op的绝对值小(资源不可用),那么以下的条件应用:
a、如果IPC_NOWAIT被指定,那么semop返回EAGAIN的错误。
b、如果IPC_NOWAIT没有被指定,那么这个信号量的semncnt值被增加(因为调用者即将睡眠),而调用进程被挂起,直到以下条件发生:
i、信号量的值变得大于或等于sem_op的绝对值(也就是说,一些其它进程已经释放了一些资源)。这个信号量的semncnt值被减少(因为调用进程已经完成等待),sem_op的绝对值也从信号量的值里被减去。如果undo标志被指定,那么sem_op的绝对值也为这个进程加到信号量的调整值里。
ii、信号量从系统上被删除。在这种情况下,函数返回一个EIDRM错误。
iii、一个信号被进程捕获,而且信号处理机返回。在这种情况下,这个信号量的semncnt的值减少(因为调用进程不再等待),函数返回一个EINTR的错误。
3、如果sem_op为0,那么这表示这个调用进程想等待,直到信号量的值变为0。
如果信号量的值当前为0,那么函数立即返回。
如果信号量的值不是0,那么以下条件应用:
a、如果IPC_NOWAIT被指定,那么返回一个EAGAIN错误。
b、如果IPC_NOWAIT没有被指定,那么这个信号量的semzcnt值被增加(因为调用者即将睡眠),调用进程被挂起,直到以下某个情况发生:
i、信号量的值变为0。这个信号量的semzcnt的值减少(因为调用进程已经完成等待)。
ii、信号量从系统中被删除。这种情况下,函数返回一个EIDRM的错误。
iii、一个信号被进程捕获且信号处理机返回。这种情况下,这个信号量的semzcnt值减少(因为调用进程不再等待),函数返回一个EINTR的错误。
semop函数自动操作,它或者完成数组里的所有操作,或者一个都不完成。
在exit时的信号量调整(Semaphore Adjustment on exit)
正如我们之前提到的,如果一个进程在终止时没有释放分配的信号量会有问题。每当我们为一个信号量操作指定SEM_UNDO标志并分配资源(一个小于0的sem_op)时,内核记住了多们从那个特定信号量里分配了多少资源(sem_op的绝对值)。当进程终止时,不管是否自愿,内核检查进个进程是否有任何突出的信号量调整值,如果有,那么把这个调整值应用到对应的信号量上。
如果我们使用semctl设置一个信号量的值,使用SETVAL或SETALL命令,那么这个信号量在所有进程里的调整值都被设置0。
例子--信号量和记录锁的计时比较
如果我们在多个进程间共享单个资源,那么我们可以使用信号量或者记录锁。比较这两种技术之间的计时区别是有趣的。
使用一个信号量,我们创建一个由单个成员组成的信号量集,并把信号量的值初始化为1。要分配这个资源,我们用一个-1的sem_op来调用semop,要释放资源,我们执行一个+1的sem_op。我们也为每个操作指定SEM_UNDO,来处理一个进程终止而不释放资源的情况。
使用记录锁时,我们创建一个空文件并使用文件的第一个字节(它不必存在)作为锁字节。为了分配内存,我们得到这个字节的写锁,为了释放它,我们解锁这个字节。记录锁的特性保证了如果一个进程握住一个锁时终止,那么这个锁会自动被内核释放掉。
下表展示了在Linux上执行这两个锁技术所需的时间。在每种情况下,资源被分配然后被释放100000资。这由三个不同的进程同时完成。下表是三个进程总的秒数。
操作 | 用户 | 系统 | 挂钟 |
---|---|---|---|
带有undo的信号量 | 0.38 | 0.48 | 0.86 |
建议记录锁 | 0.41 | 0.95 | 1.36 |
在Linux上,记录锁相比于信号量锁在挂钟时间上有大约60%的惩罚。
即使记录锁比信号量锁慢,然而如果我们正锁住单个资源(比如一个共享内存段)而不需要XSI信号量的所有昂贵的特性,那么记录锁会更好。原因是它用起来简单得多,而且系统在一个进程终止时会关照任何苟延残喘的锁。
15.9 共享内存
共享内存允许两个或多个进程共享内存的一块给定的区域。这是最块形式的IPC形式,因为数据不必在客户和服务器之间拷贝。使用共享内存的唯一的麻烦是同步多个进程对给定区域的访问。如果服务器正把数据放置到一个共享内存区域里,那么客户不应用尝试访问这个数据,直到服务器完成。信号量经常被用来同步共享内存的访问。(但是正如我们在前一节末看到的,记录也可以被用。)
SUS包含另一组可替代的接口来访问共享内存,在实时扩展的共享内存对象选项里。我们在本文不讨论实现扩展。
内核维护一个结构体,为每个共享内存段包含至少以下成员:
struct shmid_ds {
struct ipc_perm shm_perm; /* see Section 15.6.2 */
size_t shm_segsz; /* size of segment in bytes */
pid_t shm_lpid; /* pid of last shmop() */
pid_t shm_cpid; /* pid of creator */
shmatt_t shm_nattch; /* number of current attaches */
time_t shm_atime; /* last-attach time */
time_t shm_dtime; /* last-detach time */
time_t shm_ctime; /* last-change time */
...
};
(每个实现加入其它所需的结构体成员来支持共享内存段。)
类型shmatt_t被定义为一个无符号整型,至少和一个无符号短整型一样大。下表列出影响共享内存的系统限量(15.6.3节)。
描述 | 典型值 | |||
---|---|---|---|---|
FreeBSD | Linux | Mac | Solaris | |
一个共享内存段的最大字节尺寸 | 33554432 | 33554432 | 4194304 | 8388608 |
一个共享内存段的最小字节尺寸 | 1 | 1 | 1 | 1 |
系统范围的共享内存段的最大数量 | 192 | 4096 | 32 | 100 |
每个进程的共享内存段的最大数量 | 128 | 4096 | 8 | 6 |
第一个调用的函数通常是shmget,来得到一个共享内存标识符。
#include
int shmget(key_t key, size_t size, int flag);
成功返回共享内存ID,错误返回-1。
在15.6.1节,我们描述了转换key到标识符以及是一个新段被创建还是一个已有段被引用的规则。当一个新的段被创建时,shmid_ds结构体的以下的成员被初始化。
1、ipc_perm结构体被初始化为15.6.2节描述的那样。这个结构体的mode成员被设为flag的对应权限位。这些权限通过15.6.2节里的表里的值来指定。
2、shm_lpid、shm_nattach、shm_atime、和shm_dtime都被设为0。
3、shm_ctime被设为当前时间。
4、shm_segsz被设为请求的size。
size参数是共享内存段的字节尺寸。实现经常会将这个尺寸往上取到系统页尺寸的倍数,但是如果一个应用指定size为一个不是系统页尺寸的整数倍的值,那么最后页的剩余数据将不会被使用。如果一个新段被创建(典型地在一个服务器里),那么我们必须指定它的size。如果我们正引用一个已有的段(一个客户),那么我们可以指定size为0。当一个新的段被创建时,段的内容被初始化为0。
shmctl函数是各种共享内存操作的杂烩。
#include
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
成功返回0,错误返回-1。
cmd参数指定以下5个要在shmid所指定的段上执行的命令的某一个。
IPC_STAT:得到这个段的shmid_ds结构体,存储到buf所指的结构体里。
IPC_SET:从buf指向的结构体设置和这个共享内存段相关的shmid_ds结构体里的以下在三个域:shm_perm.uid、shm_perm.gid、和shm_perm.mode。这个命令只能被其有效用户ID等于shm_perm.cuid或shm_perm.uid,或有超级用户权限的进程执行。
IPC_RMID:从系统删除共享内存段。因为附加计数为共享内存段而维护(shmid_ds结构体里的shm_nattch域),所以段不被删除,直到最后使用段的进程终止或分离了它。不管这个段是否在使用,段的标识符被立即删除以便shmat不再能附加到这个段。这个命令只能被其有效用户ID等于shm_perm.cuid或shm_perm.uid,或有超级用户权限的进程执行。
SHM_LOCK:锁住内存里的共享内存段。这个命令只能被超级用户执行。
SHM_UNLOCK:解锁共享内存段。这个命令只能被超级用户执行。
一旦一个共享内存段被创建,一个进程把它附加到它的地址空间,通过调用shmat。
#include
void *shmat(int shmid, const void *addr, int flag);
成功返回共享内存段的指针,错误返回-1。
这个在调用进程里的被这个段附加的地址取决于addr参数和SHM_RND位是否在flag里被指定。
1、如果addr为0,那么段被附加到由内核选择的第一个可用的地址。这是推荐的技术。
2、如果addr不为0而SHM_RND不被指定,那么段被附加到addr给出的地址上。
3、如果addr不为0而SHM_RND被指定,那么段被附加到(addr-(addr % SHMLBA))给定的地址。SHM_RND命令表示“round”,SHMLBA表示“low boundary address multiple” 并总是2的幂。这个算法做的事是把地址往下舍到下一个SHMLBA的倍数。
除非我们准备只在单个类型的硬件上运行这个程序(当今是很不可能的事情),不然我们不该指定段被附加的地址。事实上,我们应该指定一个0的addr并让系统选择这个地址。
如果SHM_RDONLY位在标志里被指定,那么段被只读地附加。否则,段被附加为可读写。
shmat返回的段被附加的地址,或-1如果有错误发生。如果shmat成功,那么内核将增加共享内存段关联的shmid_ds结构体里的shm_nattch计数。当我们使用完一个共享内存段时,我们调用shmdt来分离它。注意这不会从系统删除标识符和它关联的数据结构。标识符保持存在,起不到一个进程(通常是服务器)明确地删除它,通过调用命令为IPC_RMID的shmctl。
#include
int shmdt (void *addr);
成功返回0,错误返回-1。
addr参数是前一个shmat调用返回的值。如果成功,shmdt将减少在shmid_ds结构体里的shm_nattch计数。
附加到地址为0的共享内存段被一个内核放置的位置是高度系统相关的。下面的代码打开一些关于一个特定系统放置各种类型数据的位置的信息。
可以发现bss位置最低,往上是堆,再往上是共享内存,最上面是栈。也就是说,共享内存在堆和栈之间。
回想下mmap函数(14.9节)可以用来把一个文件的部分映射到一个进程的地址空间。这和使用shmatXSI IPC函数附加一个共享内存段在概念上是相似的。主要区别是mmap映射的内存段是一个文件的前端,而一个XSI共享内存端不和任何文件相关联。
共享内存可以在不相关的进程间使用,但是如果进程是相关的,那么一些实现提供一个不同的技术。
下面的技术工作在FreeBSD、Linux和Solaris上。Mac当前不支持字符设备到一个进程的地址空间的映射。
设备/dev/zero在被读0无尽地产生字节0。这个设备也接受任何向它写入的数据,并忽略这个数据。我们对为IPC使用这个设备的很有兴趣,因为当它被内存映射时它的特殊属性。
1、一个无命名的区域被创建,它的尺寸是mmap的第二个参数,向上取到系统上最近的页尺寸。
2、内存区域被初始化为0.
3、多个进程可以共享这个区域,如果一个通用祖先为mmap指定了MAP_SHARED标志。
下面的代码是使用这个特殊设备的例子。
父子然后交替运行,增加在共享内存映射区域里的长整型值,使用8.9节的同步函数。内存映射区域被mmap初始化为0。父进程把它加到1,子进程接着把它加到2,父进程再把它加到3,如此下去。注意我们必须使用括号,当在update函数里增加这个长整型值时,因为我们增加值面不是指针。
在我们已经展示的行为里使用/dev/zero的优点是在调用mmap来创建映射区域之前真实文件不必存在。映射/dev/zero自动创建一个指定尺寸的映射区域。这个技术的缺点是它只在相关进程之间工作。然而,对于相关的进程,使用线程很可能会更简单和更高效(11、12章)。注意不管哪种技术被使用,我们仍需要同步共享数据的访问。
许多实现提供匿名内存映射,一个和/dev/zero特性类似的设施。为了使用这个设施,我们为mmap指定MAP_ANON标志,并指定文件描述符为-1。结果区域是匿名的(因为通过一个文件描述符没有相关的路径名)并创建一个可以被后代进程共享的内存区域。
匿名内存映射设施被本文四个平台支持。然而,注意,Linux为这个设施定义了MAP_ANONYMOUS标志,但定义了MAP_ANON为相同的值以提高应用可移植性。
为了修改上面的代码来使用这个设施,我们作三处改动:a、删除/dev/zero的open,b、删除fd的close、c、改变mmap的调用为:
if ((area = mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_ANON | MAP_SHARED, -1, 0)) == MAP_FAILED)。
在这个调用里, 我们指定了MAP_ANON标志并设置文件描述符为-1。剩余的代码没有改变。
最后两个例子演示了在相关进程之间的共享内存。如果共享内存在不相关的进程间需要,那么有两个替代方案。应用可以使用XSI共享内存函数,或使用mmap来映射相同的文件到它们的地址空间,使用MAP_SHARED标志。
15.10 客户-服务器属性(Client-Server Properties)
让我们深入讨论下客户和服务器的一些被各种类型的在它们之间使用的IPC影响的属性。最简单的关系类型是让子进程fork并exec所需的服务器。在fork前可以创建两个半双工管道来允许数据双向传输。被执行的服务器可以是一个设置用户ID程序,给予它特殊的权限。同样,服务器可以确定客户的真实标识符,通过查看它的真实用户ID。(回想8.10节真实用户ID和组用户ID不会通过exec改变。)
随着这种排列,我们可以建立一个打开服务器。(我们在17.5节展示这种客户-服务器的一个实现。)它为客户端打开文件而不是客户端调用open函数。这种方法,额外的权限检查可以被加入,在普通UNIX系统用户/组/其他人权限之上。我们假定服务器是一个设置用户ID程序,给了它额外的权限(可能是根权限)。服务器使用客户的真实用户ID来确定是否给它所请求的文件的访问权限。这种方法,我们可以建立一个服务器,允许他们通常不会有的特定用户权限。
在这个例子里,因为服务器是父进程的一个子进程,所以这个服务器能做的所有事情就是把文件的内容传回给父进程。尽管这对于普通文件可以工作,但是它不能用在特殊设备文件。我们想要能够让服务器打开所请求的文件并传回文件的描述符。一个父进程可以传递给一个子进程一个打开的描述符,而一个子进程不能传回一个描述符给父进程(除非特殊的编程技术被使用,17章)。
我们在早先展示了下一种类型的服务器。这种服务器是一个守护进程,使用某种形式的IPC被所有子进程联系。我们不能为这种类型的客户-服务器使用管道。需要某种形式的命名IPC,比如FIFO或消息队列。通过FIFO,我们看到每个客户也需要一个独立的FIFO,如果服务器要把数据发回给客户。如果客户-服务器应用只从客户端发送数据到服务器,那么单个被熟知的FIFO就足够。(系统V行打印机假脱机程序使用这种形式的客户-服务器布局。客户端是lp命令,而服务器是lpsched守护进程。单个FIFO被使用,因为数据只从客户端流向服务器。没有东西被发送回给客户端。)
使用消息队列有多种可能性。
1、单个队列可以在服务器和所有客户端之间使用,使用每个消息的类型域来指出消息的接受者。例如,客户可以用1的类型域来发送它们的请求。客户端进程ID必须包含在请求里。服务器只接受1的类型域的消息(msgrcv的第4个参数),而客户端只接受类型域和它们进程ID相等的消息。
2、另一种方法是,每个客户端可以使用独立的消息队列。在发送第一个请求给一个服务器之前,每个客户端创建它自己的消息队列,用IPC_PRIVATE的关键字。服务器也有它自己的队列,使用一个被所有客户知道的关键字或标识符。客户端发送它的第一个请求给服务器的被熟知的队列,而且请求必须包含客户队列的消息队列ID。服务器发送它第一个请求到这个客户队列,而所有将来的请求和响应都在这个队列上交换。
这个技术的一个问题是每个客户相关的队列通常只有单个消息:为服务器的一个请求或为一个客户的响应。这似乎是有限系统资源的浪费(一个消息队列),而一个FIFO可以用来代替。另一个问题是服务器必须从多个队列里读消息。select或poll都不能在消息队列上工作。
这两种使用消息队列的技术可以用共享内存段和一个同步方法(一个信号量或记录锁)来实现。
这种客户-服务器关系(客户和服务器是无关进程)的问题是服务器要精确地标识客户。除非服务器正执行一个非特权的操作,否则服务器很需要知道客户是谁。例如,如果服务器是一个设置用户ID程序,那么就很需要。尽管所有这些形式的IPC都经过内核,但是它们没有提供设置来让内核标识发送者。
使用消息队列,如果单个队列在客户和服务器之间使用(例如为了同时只有单个消息在这个队列上),那么队列的msg_lspid包含另一个进程的进程ID。但是当写服务器时,我们想要客户端的有效用户ID,而不是它的进程ID。没有可移植的通过进程ID得到有效有户ID的方法。(自然地,内核在进程表项里维护了这两个值,但是除了翻查内核的内存,我们不能通过某个得到另一个。)
我们将使用17.3节里的以下技术来允许服务器标识客户。相同的技术可以和FIFO、消息队列、信号量、或共享内存一起使用。为了以下的描述,假定FIFO被使用。客户必须创建它自己的FIFO并设置FIFO的文件访问权限,以便只有用户读和用户写是开启的。我们假定服务器有超级用户权限(否则它很可能不关心客户的真实身份),所以服务器仍可以读和写这个FIFO。当服务器在被熟知的FIFO上接收到客户的第一个请求时(它必须包含客户客户相关的FIFO的标识符),服务器在客户相关的FIFO上调用stat或fstat。服务器假定客户的用效用户ID是FIFO的属主(stat结构体的st_uid域)。服务器验证只有用户读和用户写权限被开启。作为另一个检查,服务器也应该查看和FIFO关联的三个时间(stat结构体里的st_atime、st_mtime、和st_ctime域)来验证它是是最近的(例如不超过15或30秒之前)。如果一个恶意用户可以创建一个FIFO,用其它人作为属主并设置文件的权限位为用户读和用户写,那么系统有其它基本的安全问题。
为了和XSI IPC一起使用这个技术,回想和每个消息队列、信号量、和共享内存标识符相关联的ipc_perm结构体标识了IPC结构体的创建者(cuid和cgid域)。作为一个使用FIFO的例子,服务器应该需要客户端创建IPC结构体并让客户端设置访问权限为只有用户读和用户写。和IPC结构相关的时间同样也需要被服务器验证为最近的(因为这些IPC结构体挂住直到显式地被删除。)
我们将在17.2.2节看到一个好的多的方式来为内核执行这个身份认证来提供客户的有效用户ID和有效组ID。这通过STREAMS子系统完成,当文件描述符在两个进程间传递时。
15.11 总结
我们已经深入探讨了许多形式的进程间通信:管道、命名管道(FIFO),和三个普遍称为XSI IPC的IPC形式(消息队列、信号量和共享内存)。信号量其实是一个同步原始例程,不是真实的IPC,并通常用来同步对一个共享资源的访问,比如一个共享内存段。通过管道,我们看了popen函数的实现、看了协进程,已经标准I/O库的缓冲带来的缺陷。
在比较消息队列和全双工管道的计时、信号量和记录锁的计时后,我们可以做出如下建议:学习管道和FIFO,因为这两个基本技术仍可以被有效地使用在许多应用里。避免在新的应用里使用消息队列和信号量。全双工管道和记录锁应该被考虑,因为它们要简单地多。共享内存仍然有它的用处,尽管相同的功能可以通过mmap函数的使用来提供(14.9节)。
在下章,我们将看下网络IPC,它允许进程跨过机器边界来通信。
16.1 引言
在前一章,我们看了管道、FIFO、消息队列、信号量和共享内存:这些经典的IPC方法被各种UNIX系统提供。这些机制允许在相同计算机上运行的进程彼此之间通信。在本章,我们看下允许在不同计算机上运行的进程(连接到一个通用网络)来和彼此通信的机制:网络IPC。
在本意,我们描述套接字网络IPC接口,它可以被进程用来和其它进程通信,不管它们在哪运行:在相同机器或不同的机器上。事实上,这是套接字接口的设计目标。相同的接口可以用在机器内部或机器之间的通信。尽管套接字接口可以用许多不同的网络协议通信,但是我们在本章将限制我们的讨论到TCP/IP协议,因为它事实上是因特网的通信标准。
套接字API由POSIX.1基于4.4BSD套接字接口规定。尽管这些年有些小改动,但是当前套接字接口和在上世纪80年代初最早引入到4.2BSD里的接口很相似。
这章只是套接字API的一个总览。
16.2 套接字描述符
一个套接字是一个通信终端的一个抽象。正如使用文件描述符访问文件一样,应用使用套接字描述符来访问套接字。套接字描述符在UNIX系统里作为文件描述符实现。事实上,许多处理文件描述里的函数,比如read和write,都可以工作在一个套接字描述符里。
要创建一个套接字,我们调用socket函数。
#include
int socket(int domain, int type, int protocol);
成功返回文件(套接字)描述符,错误返回-1。
domain参数决定了通信的本质,包括地址格式(下一节更深入描述)。下表总结了由POSIX.1规定的域。这些常量以AF_开头(表示address family),因为每个域都有它自己表示地址的格式。
域 | 描述 |
---|---|
AF_INET | IPv4因特网域 |
AF_INET6 | IPv6因特网域 |
AF_UNIX | UNIX域 |
AF_UNSPEC | 未指定 |
我们在17.3节讨论UNIX域。多数系统也定义AF_LOCAL域,作为AF_UNIX的一个代名。AF_UNSPEC域是一个通配符,表示“任何”域。历史上,一些平台提供补充网络协议的支持,比如AF_IPX表示NetWare协议族,但是这些协议的域常量没有定义在POSIX.1标准里。
type参数决定了套接字的类型,它更进一步决定了通信的特性。POSIX.1定义的套接字类型在下表中汇总,但是实现可以自由加入补充类型的支持。
类型 | 描述 |
---|---|
SOCK_DGRAM | 固定长度、无连接、不可靠的消息 |
SOCK_RAW | IP的数据报接口(POSIX.1的可选项) |
SOCK_SEQPACKET | 固定长度、序列化的、可靠的、面向连接的消息 |
SOCK_STREAM | 序列化的、可靠的、双向的、面向连接的字节流 |
protocol参数通常为0,来为给定的域和套接字类型选择默认的协议。当多个协议为相同的域和套接字类型支持时,我们可以使用protocol参数来选择一个特定的协议。在AF_INET域里的SOCK_STREAM的默认协议是TCP(Transmission Control Protocol,传输控制协议)。AF_INET通信域里的SOCK_DGRAM套接字的默认协议是UDP(User Datagram Protocol,用户数据报协议)。
通过一个数据报(SOCK_DGRAM)接口,在通信伙伴之间不需要逻辑连接。你所需要做的所有事是发送一个消息,地址为伙伴进程使用的套接字。
因此,一个数据报提供一个无连接的服务。另一方面,一个字节流(SOCK_STREAM),要求在你能交换数据之前,你必须在你的套接字和你想通信的伙伴所拥有的套接字之间设立一个逻辑连接。
一个数据报是自包含的消息。发送一个数据报类似于给某人邮件一封信。你可以邮寄许多信,但是你不能保证分发的顺序,而且一些可能在路上丢失。每封信包含接受者的地址,让这封信和其它的区别开来。每封信甚至可以发给不同的收件人。
相比之下,使用一个面向连接的协议来与伙伴通信就像打电话。首先,你需要建立一个连接,通过拨打电话,但是在连接建立好后,你可以和对方双向的交流。连接是点对点的通信渠道,基于此你可以说话。你的话语没有包含地址信息,就像一个点对点的虚拟连接存在于电话两端之间,而连接本身隐含了特殊的起点和终点。
通过SOCK_STREAM套接字,应用不知道消息的边界,因为套接字提供一个字节流服务。这意味着当我们从一个套接字读取数据时,它可能不返回和发送我们数据的进程所写的相同的字节量。我们将最终得到任何发送给我们的东西,但是它可能花费了几个函数调用。
一个SOCK_SEQPACKET套接字就像一个SOCK_STREAM套接字,除了我们得到一个基于消息的服务而不是一个字节流服务。这表示从SOCK_SEQPACKET套接字收到的数据量和被写的量相同。流控制传输协议(Stream Control Transmission Protocol,SCTP)在因特网域里提供一个序列化的包服务。
一个SOCK_RAW套接字提供一个数据报接口,直接到底下的网络层(意思是因特网域里的IP)。当使用这个接口时,应用需要建立它们自己的协议头,因为传输协议(例如TCP和UDP)被绕过了。需要超级用户权限来创建一个裸套接字来阻止恶意用户应用创建可能绕过设立好的安全机制的包。
调用socket和调用open相似。在两种情况下,你都得到一个可被I/O使用文件描述符。当你使用完这个文件描述符时,你调用close来放弃对文件或套接字的访问,并释放文件描述符以便重用。
尽管一个套接字描述符事实上是一个文件描述符,但是你不能连同任何接受一个文件描述符参数的函数来使用一个套接字描述符。下表总结了至今我们已经描述过的能和文件描述符一起使用的函数的大多数,并描述它们在与一个套接字描述符一起使用时会发生什么。未规定和实现定义的行为通常表示函数不能和套接字描述符一起使用。例如,lseek不能用在套接字上,因为套接字不支持文件偏移量的概念。
函数 | 套接字上的行为 |
---|---|
close(3.3节) | 释放这个套接字 |
dup、dup2(3.12节) | 和通常一样复制这个文件描述符 |
fchdir(4.22节) | 失败,errno设为ENOTDIR |
fchmod(4.9节) | 未规定 |
fchown(4.11节) | 实现定义 |
fcntl(3.14节) | 支持一些命令,包括F_DUPFD、F_GETFD、F_GETFL、F_GETOWN、F_SETFD、F_SETTL、和F_SETOWN |
fdatasync、fsync(3.13节) | 实现定义 |
fstat(4.2节) | 一些stat结构体成员被支持,但是实现定义如何支持 |
ftruncate(4.13节) | 未规定 |
getmsg、getpmsg(14.4节) | 如果套接字用STREAMS(在Solaris上)实现可以工作 |
ioctl(3.15节) | 一些命令可工作,取决于底下的设备驱动 |
lseek(3.6节) | 实现定义的(通常失败,errno设置为ESPIPE) |
mmap(4.9节) | 未规定 |
poll(14.5.2节) | 如期望地工作 |
putmsg、putpmsg(14.4节) | 如果套接字用STREAMS(在Solaris上)实现可以工作 |
read(3.7节)和readv(14.7节) | 和recv(16.5节)等价,除了没有标志 |
select(14.5.1节) | 如期望地工作 |
write(3.8节)和writev(14.7节) | 和send(16.5节)等价,除了没有标志 |
在一个套接字上的通信是双向的。我们可以用shutdown函数来禁用一个套接字上的I/O。
#include
int shutdown(int sockfd, int how);
成功返回0,错误返回-1。
如果how是SHUT_RD,那么从套接字读被禁用。如果how为SHUT_WR,那么我们不用使用这个套接字来传输文件。我们可以用SHUT_RDWR来同时禁用数据传输和接收。
既然我们可以close一个套接字,为什么需要shudown呢?有几个原因。首先,close将会只当最后活动的引用被关闭时才释放网络终端。这表示如果我们复制这个套接字(例如用dup),那么套接字不会被释放,直到我们关闭最后引用它的文件描述符。shutdown函数允许我们令一个套接字失效,和引用它的活动文件描述符的数量无关。其次,有时只关闭一个方向是很方便的。例如,如果我们想我们正通信的进程可以确定我们何时完成数据传输,却仍允许我们使用这个套接字接收这个进程发送给我们的数据,那么我们可以关闭一个套接字的写。
16.3 寻址(Addressing)
在前一节,我们学习到了如何创建和销毁一个套接字。在我们学习用一个套接字做一些有用的事之前,我们需要学习如何标识一个我们想要通信的进程。标识进程有两个部分。机器的网络地址帮助我们标识在网络上的我们想要联系的计算机,而服务帮助我们标识计算机上的特定进程。
16.3.1 字节顺序(Byte Ordering)
当和相同计算机上运行的进程通信时,我们通常不必担心字节顺序。字节顺序是处理器架构的一个特性,表明字节如何在更大的数据类型里排序,比如整型。下图展示了一个32位整型里的字节是如何编号的。
如果处理器架构支持从大到小(big-endian)字节顺序,那么最高字节地址刚好是最低有效字节(least significant byte,LSB)。从小到大(Little-endian)字节顺序相反:最低有效字节包含最低字节地址。注意不论字节如何排序,最高有效字节(MSB)总是在左边,而最低有效字节总是在右边。因而,如果我们赋给一个32位整型0x04030201的值,那么最高有效字节将会包含4,而最低有效位会包含1,不管字节顺序。如果我们然后把一个字符指针(cp)转换成一个整型地址,我们将会从字节排序看到区别。在一个从小到大的处理器上,cp[0]将指向最低有效字节并包含1;cp[3]将指向最高有效字节并包含4。相比之下,从大到小的处理器上,cp[0]会包含4,引用最高有效字节,而cp[3]会包含1,引用最低有效字节。下表总结了本文讨论的四个平台的字节顺序。
操作系统 | 处理器架构 | 字节顺序 |
---|---|---|
FreeBSD 5.2.1 | Intel Pentium | 从小到大 |
Linux 2.4.22 | Intel Pentium | 从小到大 |
Mac OS X 10.3 | PowerPC | 从大到小 |
Solaris 9 | Sun SPARC | 从大到小 |
进一步令人困惑的事情时,一些处理器可以被配置为从小到大或从大到小的操作。
网络协议指定了一个字节顺序,以便异构的计算机系统可以交换协议信息,而不必被字节顺序困扰。TCP/IP协议套件使用从大到小的字节顺序。当应用交换格式化数据时,字节顺序变得对它们可见。通过TCP/IP,地址以网络字节顺序表示,所以应用有时需要在处理器字节序和网络字节序之间转换它们。例如,在以人可读的形式打印一个地址时这很普遍。
四个通用的函数被提供给TCP/IP应用来在处理器字节序和网络字节序之间转换。
#include
uint32_t htonl(uint32_t hostint32);
返回以网络字节序的32位整型数。
unit16_t htons(uint16_t hostint16);
返回以网络字节节的16位整型数。
uint32_t ntohl(uint32_t netint32);
返回以主机字节序的32位整型。
uint16_t ntohs(uint16_t netint16);
返回以主机字节序的32位整型。
h代表“host”字节序,n表示“network”字节序。l表示“long”(4字节)整型,s表示“short”(2字节)整型。这四个函数定义在
16.3.2 地址格式(Address Formats)
一个地址标识了在一个特定通信域里的一个套接字终端。地址格式和特定的域相关。为了使不同格式的地址可以被传给套接字函数,地址需要被转换成通用的sockaddr地址结构体:
struct sockaddr {
sa_family_t sa_family; /* address familiy */
char sa_data[]; /* varible-length address */
...
};
实现可以自由加入额外的成员,并为sa_data成员定义一个尺寸。例如,在Linux上,结构体被定义为:
struct sockaddr {
sa_family_t sa_famility; /* address family */
char sa_data[14]; /* variable-length address */
};
但是在FreeBSD,结构体被定义为:
struct sockaddr {
unsigned char sa_len; /* total length */
sa_family_t sa_family; /* address family */
char sa_data[4]; /* variable-length address */
...
};
因特网地址被定义在
struct in_addr {
in_addr_t s_addr; /* IPv4 address */
};
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in_addr sin_addr; /* IPv4 address */
};
in_port_t数据类型被定义为一个unit16_t。in_addr_t数据类型被定义为一个uint32_t。这些整型数据类型指明数据类型的位数并被定义在
与AF_INET域相反,IPv6因特网域(AF_INET6)套接字地址由一个sockaddr_in6结构体表示:
struct in6_addr {
uint8_t s6_addr[16]; /* IPv6 address */
};
struct sockaddr_in6 {
sa_family_t sin6_family; /* address family */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* traffic class and flow info */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* set of interfaces for scope */
};
这些是由SUS要求的定义。个体实现可以自由加入额外的域。例如,在Linux上,sockaddr_in结构体被定义为:
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in_addr sin_addr; /* IPv4 address */
unsigned char sin_zero[8]; /* filter */
};
这里sin_zero成员是应该全设为0的一个过滤器域。
注意尽管sockaddr_in和sockaddr_in6结构体很不相同,但是它们都被套接字例程转换为sockaddr结构体。在17.3节,我们将看到UNIX域套接字地址的结构体和两个因特网域套接字地址格式都不同。
有时需要打印一个地址,以人可理解的而不是计算机可理解的格式。BSD网络软件包含了inet_addr和inet_ntoa函数来在字节地址格式和以带点数(dotted-decimal)标记法的字符串(a.b.c.d)。然而,这些函数只能工作在IPv4地址上。两个新函数--inet_ntop和inet_pton--支持相似的功能并同时与IPv4和IPv6地址工作。
#include
const char *inet_ntop(int domain, const void *restrict addr, char *restrict str, socklen_t size);
成功返回地址字符串的指针,错误返回NULL。
int inet_pton(int domain, const char *restrict str, void *restrict addr);
成功返回1,格式无效返回0,错误返回-1。
inet_ntop函数把一个网络字节顺序的二进制地址转换为一个文本字符串;inet_pton把一个文本字符串转换为网络字节顺序的一个二进制地址。只有两俱域值被支持:AF_INET和AF_INET6。
对于inet_ntop函数,size参数指明装载文本字符串的缓冲(str)的尺寸。两个常量被定义来使我们的工作更容易些:INET_ADDRSTRLEN足够大以容纳表示一个IPv4地址的文本字符串,而INET6_ADDRESTRLEN足够大以容纳一个表示IPv6地址的文本字符串。对于inet_pton,addr缓冲需要足够大以容纳一个32位地址,如果域是AF_INET或足够大以容纳一个128位地址,如果域是AF_INET6。
16.3.3 地址查询
理想状态下,一个应用不必知道一个套接字地址的内部结构。如果一个应用简单地传递一个套接字地址,作为sockaddr结构体,并没有依赖任何协议相关的特性,那么应用将可以和许多提供相同类型服务的不同协议一起工作。
历史上,BSD网络软件提供了访问各种网络配置信息的接口。在6.7节,我们概括地讨论了网络数据文件和用来访问它们的函数。在本节,我们更深入点讨论它们并介绍更新的用来查询寻址信息的函数。
这些函数返回的网络配置信息可以保存在许多地方。它们可以被保存在静态文件里(/etc/hosts、/etc/services、等),或者它们可以用一个命名服务管理,比如DNS(域名系统,Domain Name System)或NIS(网络信息服务,Network Information Service)。不管哪种信息被保存,相同的函数可以被用来访问它。
一个给定计算机系统知道的主机,可以通过调用gethostent来找到。
#include
struct hostent *gethostent(void);
成功返回指针,错误返回NULL。
void sethostent(int stayopen);
void endhostent(void);
如果主机数据库文件还没有被打开,gethostent将会打开它。gethostent函数返回文件里的下一个项。sethostent函数将会打开这个文件或在文件已打开时回滚这个文件。endhostent会关闭这个文件。
当gethostent返回时,我们得到一个hostent结构体的指针,它可能静态数据缓冲,每次我们调用gethostent时都会覆写它。hostent结构体被定义为有至少以下成员:
struct hostent {
char *h_name; /* name of host */
char **h_aliases; /* pointer to alternate host name array */
int h_addrtype; /* address type */
int h_length; /* length in bytes of address */
char **h_addr_list; /* pointer to array of network addresses */
...
};
返回的地址是网络字节顺序的。
两个额外的函数--gethostbyname和gethostbyaddr--最初和hostent函数一起被引入,但是现在被视为废弃的。我们很快将看到它们的替代品。
我们可以得到网络名字和号,使用相似的接口集:
#include
struct netent *getnetbyaddr(uint32_t net, int type);
struct netent *getnetbyname(const char *name);
struct netent *getnetent(void);
所有在成功时返回指针,错误返回NULL。
void setnetent(int stayopen);
void endnetent(void);
netent结构体包含至少以下域:
struct netent {
char *n_name; /* network name */
char **n_aliases; /* alternate network name array pointer */
int n_addrtype; /* address type */
uint32_t n_net; /* network number */
...
};
网络号以网络字节顺序被返回。地址类型是某个地址族常量(例如AF_INET)。
我们可以在协议名和号之间进程映射,通过下面的函数:
#include
struct protoent *getprotobyname(const char *name);
struct protoent *getprotobynumber(int proto);
struct protoent *getprotoent(void);
所有成功返回指针,错误返回NULL。
void setprotoent(int stayopen);
void endprotoent(void);
protoent结构体由POSIX.1定义,有至少以下成员:
struct protoent {
char *p_name; /* protocal name */
char **p_aliases; /* pointer to alternate protocol name array */
int p_proto; /* protocol number */
...
};
服务由地址的端口号表示。每个服务被提供一个唯一的、被熟知的端口号。 我们可以用getservbyname映射一个服务名到一个端口号,用getservbyport映射一个端口号到一个服务名,或用getservent线性浏览服务数据库。
#include
struct servent *getservbyname(const char *name, const char *proto);
struct servent *getservbyport(int port, const char *proto);
struct servent *getservent(void);
成功返回指针,错误返回NULL。
void setservent(int stayopen);
void endservent(void);
servent结构体被定义为至少包含以下成员:
struct servent {
char *s_name; /* service name */
char **s_aliases; /* pointer to alternate service name array */
int s_port; /* port number */
char *s_proto; /* name of protocol */
...
};
POSIX.1定义了几个新函数来允许一个应用从一个主机各和一个服务名映射到一个地址,或以相反顺序。这些函数代替了老了gethostbyname和getostbyaddr函数。
getaddrinfo函数允许我们把一个主机名和一个服务名映射到一个地址。
#include
#include
int getaddrinfo(const char *restrict host, cosnt char *restrict service, const struct addrinfo *restrict hint, struct addrinfo **restrict res);
成功返回0,错误返回非零错误码。
void freeaddrinfo(struct addrinfo *ai);
我们需要提供主机名、服务器、或两者。如果我们只提供一个名字,另一个应该是一个空指针。主机名可以是一个结点名也可以是一个用带点数标记的主机地址。
getaddrinfo函数返回一个addrinfo结构体的链表。我们可以使用freeaddrinfo来释放一个或多个这样的结构体,取决于有多少个结构体通过ai_next域被链在了一起。
addrinfo结构体被定义为包含至少以下的成员:
struct addrinfo {
int ai_flags; /* customize behavior */
int ai_family; /* address family */
int ai_socktype; /* socket type */
int ai_protocol; /* protocol */
socklen_t ai_addrlen; /* length in bytes of address */
struct sockaddr *ai_addr; /* address */
char *ai_canonname; /* canonical name of host */
struct addrinfo *ai_next; /* next in list */
...
};
我们可以提供一个可选的hint来选择到达特定临界区的地址。hint是一个模板,用来过滤地址并只使用ai_family、ai_flags、ai_protocol和ai_socktype域。其余的整型域必须被设为0,而指针域必须为空。下表总结了我们可以在ai_flags域里使用的标志来定制如何对待地址和名字。
标志 | 描述 |
---|---|
AI_ADDRCONFIG | 查询哪种地址类型(IPv4或IPv6)被配置 |
AI_ALL | 同时查找IPv4和IPv6地址(只和AI_V4MAPPED一起使用) |
AI_CANNONAME | 查询一个规范名(canonical name)(与化名alias相反) |
AI_NUMERICHOST | 以数值格式返回主机地址 |
AI_NUMERICSERV | 作为端口号返回服务 |
AI_PASSIVE | 套接字地址被试图绑定以监听 |
AI_V4MAPPED | 如果IPv6地址被找到,那么返回以IPv6格式映射的IPv4地址 |
如果getaddrinfo失败,那么我们不能使用perror或strerror来产生一个错误消息。相反,我们需要调用gai_strerror来把返回的错误码转换为一个错误消息。
#include
const char *gai_strerror(int error);
返回描述error的一个字符串指针。
getnameinfo函数把一个地址转换到一个主机名和一个服务名。
#include
#include
int getnameinfo(const struct sockaddr *restrict addr, socklen_t alen, char *restrict host, socklen_t hostlen, char *restrict service, socklen_t servlen, unsigned int flags);
成功返回0,错误返回非0。
套接字地址(addr)被翻译为一个主机名和一个服务名。如果host非空,那么它一个hostlen字节长的缓冲,它将被用来返回主机名。相似地,如果service非空, 它指向一个servlen字节长的缓冲,它用来返回服务名。
falgs参数给我们对如果完成翻译的一些控制。下表总结了被支持的标志。
标志 | 描述 |
---|---|
NI_DGRAM | 服务是基于数据报的而不是基于流的 |
NI_NAMEREQD | 如果主机名不能被找到,把这个作为一个错误对待 |
NI_NOFQDN | 只返回本地主机的完全修饰的域的名字部分 |
NI_NUMBERICHOST | 返回主机地址的数值形式而不是名字 |
NI_NUMBERICSERV | 返回服务地址的数值形式(端口号)而不是名字 |
下面的代码演示了getaddrinfo函数的使用。
运行结果为:$ ./a.out www.google.com ftp
flags canon family inet type stream protocol TCP
host www.l.google.com address 74.125.128.106 port 21
flags canon family inet type stream protocol TCP
host - address 74.125.128.105 port 21
flags canon family inet type stream protocol TCP
host - address 74.125.128.99 port 21
flags canon family inet type stream protocol TCP
host - address 74.125.128.103 port 21
flags canon family inet type stream protocol TCP
host - address 74.125.128.147 port 21
flags canon family inet type stream protocol TCP
host - address 74.125.128.104 port 21
$ ./a.out www.google.com nfs
flags canon family inet type stream protocol TCP
host www.l.google.com address 74.125.128.106 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.106 port 2049
flags canon family inet type stream protocol TCP
host - address 74.125.128.105 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.105 port 2049
flags canon family inet type stream protocol TCP
host - address 74.125.128.99 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.99 port 2049
flags canon family inet type stream protocol TCP
host - address 74.125.128.103 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.103 port 2049
flags canon family inet type stream protocol TCP
host - address 74.125.128.147 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.147 port 2049
flags canon family inet type stream protocol TCP
host - address 74.125.128.104 port 2049
flags canon family inet type datagram protocol UDP
host - address 74.125.128.104 port 2049
(getaddrinfo从/etc/services里寻找服务。/etc/services里包含服务信息、对应的端口、使用的套接字类型(TCP/UDP)和化名。)
16.3.4 关联地址与套接字(Associating Address with Sockets)
和一个客户端关联的地址没什么令人感兴趣的,而且我们可以让系统为我们选择一个默认的地址。然而,对于一个服务器,我们需要把一个被熟知的地址关联到服务器的套接字,客户请求将在它上面到达。客户需要一种发现这个地址的方法,用来和一个服务器联系,而对于一个服务器而言最简单的机制是预留一个地址并把它注册到/etc/services或使用命名服务。
我们使用bind函数来关联一个地址和一个套接字。
#include
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
成功返回0,错误返回-1。
在我们能用的地址上有几个限制:
1、我们指定的地址必须在进程运行的机器上有效;我们不能指定属于其它机器的地址。
2、地址必须和我们用来创建套接字地址族支持的格式匹配。
3、地址里的端口号不能比1024小,除非进程有恰当的权限(超级用户)。
4、通常,只有一个套接字终端可以绑定到一个给定的地址,尽管一些协议允许重复的绑定。
对于因特网域,如果我们指定特殊IP地址INADDR_ANY,那么套接字终端会被绑定到系统的所有网络接口上。这表示我们可以从系统里安装的任何网络接口卡接收包。我们将在下节看到系统将选择一个地址并为我们把它绑定到套接字,如果我们调用connect或listen,且事先没有把一个地址绑定到这个套接字。
我们可以使用getsockname函数来发现绑定到一个套接字的地址。
#include
int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);
成功返回0,错误返回-1。
在调用getsockname之前,我们设置alenp来指向一个包含sockaddr缓冲的尺寸的整型。在返回时,这个整型被设置为返回地址的尺寸。如果地址不能放入提供的缓冲里,那么地址被静默地裁切。如果没有地址在当前绑定到这个套接字,那么结果是无定义的。
如果套接字被连接到一个伙伴,那么我们可以找到这个伙伴的地址,通过调用getpeername函数。
#include
int getpeername(int sockfd, struct sockaddr *restrict addr, socklent_t *restrict alenp);
成功返回0,错误返回-1。
除了返回伙伴的地址,getpeername函数和getsockname函数相同。
16.4 连接建立(Connection Establishment)
如果我们处理一个面向连接的网络服务(SOCK_STREAM或SOCK_SEQPACKET),那么在我们交换数据前,我们需要在请求服务的进程(客户)的套接字和提供服务的进程(服务器)的套接字之间创建一个连接。我们使用connect函数来创建一个连接。
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t len);
成功返回0,错误返回-1
我们在connect里指定的地址是我们想通信的服务器的地址。如果sockfd没有绑定到一个地址,connect会为调用者创建一个默认的地址。
当我们尝试连接一个服务器时,连接请求可能因为几种原因失败。我们尝试连接的机器必须开启且正运行,服务器必须绑定到我们尝试联系的地址,且在服务器的待定连接队列里必须有空间(我们马上会学习更多关于这个的东西)。因而,应用必须能够处理由临时情况导致的connect错误返回。
下面的代码展示一种处理临时connect错误的方法。这很可能是正运行在一个高负载系统上的一个服务器。
如果套接字描述符是非阻塞模式,16.8节深入讨论,那么connect将返回-1并设置errno为特殊的错误码EINPROGRESS,如果连接不能被立即建立。应用可以使用poll或select来确定文件描述符何是可用。在这时,连接完成。
connect函数也可以用在一个无连接的网络服务上(SOCK_DGRAM)。这可能像一个缩减,但其实是一个优化。如果我们用一个SOCK_DGRAM套接字调用connect,那么我们发送的所有消息的目的地被设置为我们在connect调用里指定的地址,使我们解脱不必每次传送一个消息时都要提供地址。此外,我们将只从我们指定的地址收到数据报。
一个服务器声明它想要接受连接请求,通过调用listen函数。
#include
int listen(int sockfd, int backlog);
成功返回0,错误返回-1。
backlog参数提供一个提示给系统,关于它应该代理进程排队的显著连接请求的数量。真实值由系统决定,但上限作为SOMAXCONN定义在
在Solaris上,
一旦队列满了,系统将拒绝额外的连接请求,所以backlog值必须基于服务器期望的负载和它必须执行的用来接受一个连接请求并启动服务的处理的所需的时间来选择。
一旦服务器调用listen,使用的套接字可以接收连接请求。我们使用accept函数来得到一个连接请求,并把它转换到一个连接。
#include
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);
成功返回文件(套接字)描述符,错误返回-1。
accept返回的文件描述符是一个套接字描述符,它被连接到调用connect的客户。这个新的套接字描述符和原始套接字(sockfd)有相同的套接字类型和地址族。传递给accept的原始套接字和这个连接没有关联,但是仍然保持可用以接收额外的连接请求。
如果我们不关心客户的身份,那么我们可以设置addr和len参数为NULL。否则,在调用accept之前,我们需要设置addr参数为一个足够大的缓冲以容纳地址,并调用len指向的整型为缓冲的尺寸。在返回时,accept将在缓冲里填充客户的地址并更新len指向的整型来返回地址的尺寸。
如果没有连接请求待定,那么accept将阻塞,直到某个请求到达。如果sockfd是非阻塞模式,那么accept将返回-1并设置errno为EAGAIN或EWOULDBLOCK。
本文四个平台都把EAGAIN定义为和EWOULDBLOCK相同的值。
如是一个服务器调用accept且没有连接请求出现,那么服务器将阻塞,直到某个到达。可替代地,一个服务器可以使用poll或select来等待一个连接请求到达。在这种情况下,一个带有待定连接请求的套接字会以可读方式出现。
下面的代码展示一个我们可以用来分配和初始化一个被服务器进程使用的函数:
16.5 数据传输(Data Transfer)
因为一个套接字终端作为一个文件描述符表示,所以我们可以使用read和write来和一个套接字通信,只要它被连接。回想一个数据报套接字可以是“连接的”如果我们使用connect函数设置默认伙伴地址。使用read和write操作套接字描述符是很有意义的,因为它表示我们可以传递套接字描述符到一个函数,它最初被设计为工作在本地文件上。我们可以安排把套接字描述符传递给执行程序的对套接字一无所知的子进程。
尽管我们可以使用read和write交换数据,但是这大概是我们用这两个函数能做的所有事情。如果我们想指定选项,从多个客户接收包,或发送不同频道的数据,那么我们需要使用为数据传输设计的6个套接字函数中的某个。
三个函数可用来发送数据,而三个可用来接收数据。首先,我们将看下用来发送数据的那些。
最简单的一个是send。它和write相似,但是允许我们指定标志来改变我们想要的数据是如何被对待的。
#include
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
成功返回发送的字节数,错误返回-1。
像write一样,套接字必须被连接才能使用send。buf和nbytes参数有和在write里时相同的意义。
然而,不像write一样,send支持第4个标志参数。两个标志被SUS定义,但是实现普遍支持补充的。它们在下表汇总。
标志 | 描述 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
---|---|---|---|---|---|---|
MSG_DONTROUTE | 不要路由本地网络之外的包 | * | * | * | * | |
MSG_DONTWAIT | 启用非阻塞操作(等价于使用O_NONBLOCK)。 | * | * | * | ||
MSG_EOR | 如果被协议支持,这是记录末尾 | * | * | * | * | |
MSG_OOB | 发送out-of-band数据,如果被协议支持(16.7节) | * | * | * | * | * |
如果send返回成功,它也不必表示连接的另一端的进程收到了数据。所有我们被保证的是当send成功时,数据被分发给网络驱动器而没有错误。
使用一个支持消息边界的协议时,如果我们尝试发送单个比协议支持的最大值还要大的消息,那么send会失败并设置errno为EMSGSIZE。使用一个基于流的协议时,send会阻塞,直到整个数据被传送。
sendto函数和send相似。区别在于sendto允许我们指定一个用于无连接套接字的目的地址。
#include
ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *destaddr, socklen_t destlen);
成功返回发送的字节量,错误返回-1。
使用一个面向连接的套接字时,目的地址被忽略,因为目的地被连接隐含。使用一个无连接的套接字时,我们不能使用send,除非目的地址事先通过调用connect设置,所以sendto给我们发送一个消息的另一种方式。
我们在通过套接字传输数据时还有一个选择。我们调用sendmsg,使用一个msghdr结构体来指明多个缓冲,从这些缓冲来传输数据,和writev函数相似(14.7节)。
#include
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
成功返回字节数,错误返回-1。
POSIX.1定义msghdr结构体,包含至少以下成员:
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address size in bytes */
struct iovec *msg_iov; /* array of I/O buffers */
int msg_iovlen; /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_controllen; /* number of ancillary bytes */
int msg_flags; /* flags for received message */
...
};
我们在14.7节看到iovec结构体。我们将在17.4.2节看到补充数据的使用。
recv函数和read相似,但允许我们指定一个选项来控制我们如何收到数据。
#include
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
返回消息的字节尺寸,如果没有可用的消息且同伴执行了一个有规则的关闭则返回0,错误返回-1。
可以传递给recv的标志在下表汇总。只有三个被定义在SUS里。
标志 | 描述 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
MSG_OOB | 如果被协议支持则接受不在同一频道的数据(16.7节) | * | * | * | * | * |
MSG_PEEK | 返回包内容而不消费包。 | * | * | * | * | * |
MSG_TRUNC | 请求被返回的包的真实长度,即使它被裁切 | * | ||||
MSG_WAITALL | 等待直到数据可用(只用于SOCK_STREAM) | * | * | * | * | * |
当我们指定MSG_PEEK标志时,我们可以瞟一下下一个要读的数据,而不真正消费它。下一个read调用或某个recv函数会返回我们瞟过的相同的数据。
使用SOCK_STREAM套接字时,我们可以接收比我们请求更少的数据。MSG_WAITALL标志抑制这种行为,避免recv返回,直到所有我们请求的数据被接收到。使用SOCK_DGRAM和SOCK_SEQPACKET套接字时,MSG_WAITALL标志在行为上没有提供改变,因为这些基于消息的套接字类型在单个读里已经返回整个消息了。
如果发送者已经调用了shutdown(16.2节)来结束传输,或网络协议默认支持有规则的关闭且发送者已经关闭这个套接字,那么recv将返回0,当我们已收到所有数据时。
#include
ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags, struct sockaddr *restrict addr, socklen_t *restrict addrlen);
返回消息的字节长度,如果没有消息可用且同伴已经执行有规则的关闭则返回0,错误返回-1。
如果addr为非空,那么它将包含套接字终端的地址,数据从那里发送过来。当调用recvfrom时,我们需要设置addrlen参数来指向一个包含addr指向的套接字缓冲字节尺寸的整型。在返回时,这个整型被设置为地址真实的字节尺寸。
因为我们被允许得到发送者的地址,所能recvfrom通常用在无连接的套接字上。否则,recvfrom和recv的行为一样。
为了接收数据到多个缓冲里,和readv类似(14.7节),或者我们想接收附加数据(17.4.2节),那么我们可以使用recvmsg。
#include
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
返回消息的字节长度,如果没有消息可用且同伴已经执行有规则的关闭则返回0,错误返回-1。
msghdr结构体(我们在sendmsg那里看过了)被recvmsg用来指定用来接收数据的输入缓冲。我们可以设置flags参数来改变recvmsg的默认行为。在返回时,msghdr结构体的msg_flags域被设置来指定收到的数据的各种特性。(msg_flags域在recvmsg的入口被忽略)。在从recvmsg返回时的可能值在下表汇总。我们将看到一个使用recvmsg的例子,在17章。
标志 | 描述 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
---|---|---|---|---|---|---|
MSG_CTRUNC | 控制数据被裁切 | * | * | * | * | * |
MSG_DONTWAIT | recvmsg以非阻塞模式被调用 | * | * | |||
MSG_EOR | 记录末尾被收到 | * | * | * | * | * |
MSG_OOB | 不同频道的数据被收到 | * | * | * | * | * |
MSG_TRUNC | 普通数据被裁切 | * | * | * | * | * |
下面的代码展示了一个和服务器通信来获得一个系统uptime命令的输出的客户端命令。我们称这个服务为“远程uptime”(或“ruptime”)。
这个程序连接到一个服务器,读取从服务器发送的字符串,并把字符串打印到标准输出。因为我们正使用一个SOCK_STREAM套接字,所以我们不能被保证我们将在一次recv调用里读取到整个字符串,所以我们需要重复这个调用,直到它返回0。
getaddrinfo函数可能为我们返回多个可使用的候选地址,如果服务器支持多个网络接口或多个网络协议。我们依次尝试每一个,当找到一个允许我们连接到服务时便放弃。我们使用connect_retry函数来建立一个到服务器的连接。
下面的代码展示了提供uptime命令的输出给上面的客户程序的服务器。
服务器通过调用gethostname得到主机名并查找远程uptime服务器的地址。可能有多个地址返回,但是我们简单挑选第一个我们可以为其建立一个被动套接字终端的那个。
我们使用16.4节的initserver函数来初始化套接字终端,在它上面我们将等待连接请求的到达。(事实上, 我们使用16.6节的版本,我们将在讨论套接字选项时看到为什么。)
(要让上面的代码正常工作,首先确保/etc/services文件有ruptime这个服务,没有的话需要加上这项。然后,在客户端程序的命令行里必须指定主机的真实名字,而不能用localhost这样的。)
在前面,我们指定使用文件描述符来访问套接字是意义重大的,因为它允许不知道网络的程序在网络环境下运行。下面的代码是上面服务器的另一个版本,它演示这点。作为从uptime命令的输出读并发送它到客户端的替代,服务器把uptime命令的标准输出和标准错误安排到和客户端连接的套接字终端。
父进程可以安全地关闭和客户连接的文件描述符,因为子进程仍然打开着它。父进程在进行运行前等待子进程完成,以便子进程不会变成僵尸。因为运行uptime命令不应该花太长时间,所以父进程可以等待得起子进程退出,在接受下个连接请求之前。然而,这个策略可能不合适,如果子进程花费很长的时间。
前面的例子使用面向连接的套接字。但是我们怎么选择合适的类型呢?我们什么时候使用面向连接的套接字,而什么时候使用一个无连接的套接字呢?答案取决于我们想完成多少工作,以及我们对错误有什么类型的容忍。
通过一个无连接套接字,包可以不按顺序到达,所以如果我们不能在一个包里填满所有数据,那么我们将必须在应用里担心排序的问题。最大的包尺寸是一个通信协议的特性。还有,通过一个无连接套接字,包可能被丢失。如果我们的应用不能容忍这种丢失,那么我们应该使用面向连接的套接字。
容忍包的丢失意味着我们有两种选择。如果我们倾向于在和我们伙伴之间要有可靠的通信,那么我们必须编号我们的包并当我们察觉到一个丢失的包时请求从伙伴应用重新发送。我们也将必须标识重复的包并舍弃它们,因为一个包可以延迟而看起来被丢失,但是在我们请求重发后又出现。
另一个选择是能让用处重试命令的方式处理这个错误。对于简单的应用,它可能足够,但是对于复杂的应用,这通常不是可行的方法,所以在这种情况下我们通常使用面向连接的套接字。
面向连接的套接字的缺陷是需要更多时间和工作来建立一个连接,且每个连接从操作系统消耗掉更多的资源。
下面的代码是uptime客户命令的另一个版本,它使用数据报套接字接口。
通过面向连接的协议,我们需要在交换数据前连接到服务器。连接请求的到达对于服务器来说已经足够确定它需要为一个客户提供服务。但是使用基于数据报的协议,我们需要一种方法来通知服务器我们想让它代理我们执行它的服务。在这个例子里,我们简单地发送给服务器一个字节的消息。服务器将收到它,从包里得到我们的地址,并使用这个地址来传送它的响应。如果服务器提供多个服务,我们可以使用这个请求消息来指明我们想要的服务,但是既然服务器只做一件事,那么一字节的消息内容无关紧要。
如果服务器没有运行,那么客户会无尽地阻塞在recvfrom调用上。如面向连接的例子一样,connect调用会失败,如果服务器没有运行。为了避免无尽地阻塞,我们在调用recvfrom前设置一个闹钟。
下面的代码是uptime服务器的数据报的版本。
16.6 套接字选项
套接字机制为我们提供两个套接字选项接口来控制套接字的行为。一个接口用来设置选项,而另一 个接口允许我们查询一个选项的状态。我们可以得到并设置三种选项:
1、普通选项,和所有套接字一起工作。
2、在套接字级被管理的,但取决于底下的协议的支持选项。
3、协议指定选项,每个协议个体只有唯一一个。
SUS只定义了套接字级的选项(上述列表里的前两个选项类型)。
我们可以用setsockopt函数设置一个套接字选项。
#include
int setsockopt(int sockfd, int level, int option, const void *val, socklen_t len);
成功返回0,错误返回-1。
level参数标识选项要应用到的协议。如果选项是一个通用套接字级的选项,那么level被设置为SOL_SOCKET,否则,level被设置为控制这个选项的协议号。例子有TCP选项的IPPROTO_TCP和IP选项的IPPROTO_IP。下表总结了由SUS定义的通用的套接字级的选项。
选项 | val参数类型 | 描述 |
---|---|---|
SO_ACCEPTCONN | int | 返回一个套接字是否为监听开启(只应用于getsockopt) |
SO_BROADCAST | int | 如果*val非0,则广播数据报 |
SO_DEBUG | int | 如果*val非0,则开启网络驱动的调试 |
SO_DONTROUTE | int | 如果*val非0,则绕过通常的路由 |
SO_ERROR | int | 返回并清理待定的套接字错误(只应用于getsockopt) |
SO_KEEPALIVE | int | 如果*val非0,则开启定期的keep-alive消息 |
SO_LINGER | struct linger | 当存在未发送的消息且套接字被关闭时的延迟时间 |
SO_OOBINLINE | int | 如果*val非0,不同频道的数据被内嵌到普通数据里 |
SO_RCVBUF | int | 接收缓冲的字节尺寸。 |
SO_RCVLOWAT | int | 当一个接收调用上要返回的数据的最少的量。 |
SO_RCVTIMEO | struct timeval | 一个套接字接收调用的计时值 |
SO_REUSEADDR | int | 如果*val非0,则重用bind里的地址 |
SO_SNDBUF | int | 发送缓冲的字节尺寸 |
SO_SNDLOWAT | int | 在一个发送调用里要传送的数据的最少的量。 |
SO_SNDTIMEO | struct timeval | 一个套接字发送调用的计时值 |
SO_TYPE | int | 标识套接字类型(只适用于getsockopt) |
val参数指向一个数据结构或一个整型,取决于选项。一些选项是开关。如果整型非0,那么选项被开启。如果整型为0,则选项被禁用。len参数指定val指向的对象的尺寸。
我们可以用getsockopt函数找到当前一个选项的值。
#include
int getsockopt(int sockfd, int level, int option, void *restrict val, socklen_t *restrict lenp);
成功返回0,错误返回-1。
注意lenp参数是一个整型指针。在调用getsockopt前,我们设置这个整型为选项要拷贝到的缓冲的尺寸。如果选项的真实尺寸比这个尺寸大,那么选项被静默地裁切。如果选项的真实尺寸比这个尺寸小,那么在返回时这个整型被更新为真实的尺寸。
16.4节的initserver函数在服务器终止而我们尝试立即重启它时不能恰当地操作。通常,TCP的实现阻止我们绑定到相同的地址,直到计时到时,它通常大概是几分钟。幸运地,SO_REUSEADDR套接字选项允许我们绕过这个限制,如下面的代码所演示的。
16.7 频道外的数据(Out-of-Band Data)
频道外数据是一个由一些通信协议支持的可选特性,允许分发的数据有比通常更高的优先级。频道外数据在任何已经排队的数据之前发送。TCP支持频道外数据,但是UDP不支持。频道外数据的套接字接口被TCP的频道外数据深深地影响。
TCP把频道外数据引用为“紧急”数据。TCP只支持单个字节的紧急数据,但是允许紧急数据从普通数据分发机制分发到频道外。为了产生紧急数据,我们为三个send函数中的任何一个指定MSG_OOB标志。如果我们用MSG_OOB标志发送多于一个字节的数据,那么后面一个字节被视为紧急数据字节。
当紧急数据被接收时,我们被发送SIGURG信号,如果我们已经为套接字产生的信号布署好了。在3.14节和14.6.2节,我们看到了我们可以在fcntl里使用F_SETOWN命令来设置一个套接字的属主。如果fcntl的第三个参数为正,那么它指定一个进程ID。如果它是一个不是-1的负数,那么它表示进程组ID。因而,我们可以安排我们的进程从一个套接字接受信号,通过调用fcntl(sockfd, F_SETOWN, pid);。
F_GETOWN命令可以用来得到当前的套接字属主。和F_SETOWN命令一样,一个负值表示一个进程组ID,而一个正值表示一个进程ID。因而,调用owner = fcntl(sockfd, F_GETOWN, 0)将会返回给owner已配置的从套接字接收信号的进程ID(正值),或绝对值为进程组ID(负值)。
TCP支持一个urgent mark的记法:在普通数据流里紧急数据可以去的点。我们可以选择把紧急数据内嵌到普通数据里来接收,如果我们使用SO_OOBINLINE套接字选项。为了帮助我们标识我们何时到达了紧急标记,我们可以用sockatmark函数。
#include
int sockatmark(int sockfd);
在标记上时返回1,不在标记上时返回0,错误返回-1。
当下一个要读的字节是紧急标记放置的地方时,sockatmark将返回1。
当频道外的数据出现在一个套接字的读队列时,select函数(14.5.1节)会返回文件描述符作为有一个待定的例外条件。我们可以选择把紧急数据内嵌在普通数据里,或者我们使用MSG_OOB标志让某个recv函数在任何其它队列数据之前接收这个紧急数据。TCP只排队一个字节的数据。如果另一个紧急字节到达在我们接收当前这个之前到达,那么已有的这个会被舍弃。
16.8 非阻塞和异步I/O(Nonblocking and Asynchronous I/O)
通常recv函数会阻塞,当没有数据立即可用时。相似地,send函数会阻塞,当套接字的输出队列里没有足够空间来发送数据时。当套接字在非阻塞模式时行为为改变。在这种情况下,这些函数会失败而不是阻塞,设置errno为EWOURLDBLOCK或EAGAIN。当这发生时,我们可以使用poll或select来确定我们可时可以接收或传送数据。
SUS的实时扩展包含对通用异步I/O机制的支持。套接字机制有它自己处理异步I/O的方法,但是这没有在SUS里标准化。一些文本引用经典的基于套接字的异步I/O机制为“基于信号的I/O”来把它和实时扩展里的异步I/O机制区别开。
通过使用基于套接字异步I/O时,我们可以布署为在从一个套接字读数据或在套接字写队列的空间变得可用时,被发送一个SIGIO信号。启用异步I/O有两步处理:
1、设立套接字的从属关系所以信号可以被分发给恰当的进程。
2、通知套接字我们想让它当I/O操作不会阻塞时给我们发送信号。
我们可以用三种方式完成第一个步骤。
1、在fcntl里使用F_SETOWN命令;
2、在ioctl里使用FIOSETOWN命令;
3、在ioctl里使用SIOCSPGRP命令。
为了完成第二个步骤,我们有两个选择。
1、在fcntl里使用F_SETFL命令并开启O_ASYNC文件标志。
2、在ioctl里使用FIOASYNC命令。
我们有几个选项,但是它们不被统一支持。下表总结了为由本文讨论的平台提供的对这些选项的支持。我们在提供支持的地方显示*,在支持取决于特定域的地方显示+。例如,在Linux上,UNIX域套拼字不支持FIOSETOWN或SIOCSPGRP。
机制 | POSIX.1 | FreeBSD | Linux | Mac | Solaris |
---|---|---|---|---|---|
fcntl(fd, F_SETOWN, pid) | * | * | * | * | * |
ioctl(fd, FIIOSETOWN, pid) | * | + | * | * | |
ioctl(fd, SIOCSPGRP, pid) | * | + | * | * | |
fcntl(fd, F_SETFL, flags|O_ASYNC) | * | * | * | ||
ioctl(fd, FIOASYNC, &n) | * | * | * | * |
16.9 总结
在本章,我们看了允许进程和不同机器上的以及在相同机器上的其它进程通信的IPC机制。我们讨论了套接字端点如何被命令以及我们当联系服务器时如何发现要使用的地址。
我们展示了使用无连接(基于数据报的)套接字和面向连接的套接字的客户端和服务器的例子。我们概括地讨论了异步和非阻塞套接字I/O和用来管理套接字选项的接口。
在下一章,我们将看到一些高级的IPC标题,包括我们如何能使用套接字来在运行在同一机器上的进程之间传输文件描述符。
17.1 引言
在前面两章,我们讨论了各种形式的IPC,包括管道和套接字。在本章,我们要看两个高级形式的IPC--基于STREAMS的管道和UNIX域套接字--和我们能用它们做什么。通过这些形式的IPC,我们可以在进程之间传递文件描述符,服务器可以把名字和它们的文件描述符关联,而客户端可以使用这些名字来和服务器集结。我们也将看到操作系统如何为每个客户提供一个唯一的IPC渠道。
17.2 基于STERAMS的管道(STREAMS-Based Pipes)
(BSD不支持STREAMS,而Linux的LiS(Linux STREAMS)早已过期,取而代之的fast STEAMS的最新版本也只是2008年,只支持Linux2.x版本,在我的Linux 3.0上无法运行。加上STREAMS的功能可以用套接字实现,所以我打算只概括地了解下它的概念,对于代码例子,我就直接拷贝过来,随便看看,不做试验了。)
一个基于STREAMS的管道(简称为STREAMS管道)是一个双向的(全双工)管道。为了在父进程和子进程之间得到双向的数据流,只需要单个STRAMS管道。
回想15.1节STREAMS管道被Solaris支持,并在Linux上作为一个插件包。
有两种角度来观察STREAMS管道。一种是管道两端在同一个进程里首尾相连,数据双向流动。另一种是两端通过内核的流管道相连。它和15.2节里的描述唯一的区别是数据是双向流动的,因为STREAMS管道是全双工的。
如果我们看一下STREAMS管道的内部,那么我们看到它是简单的两个流头,每个的写队列(WQ)都指向对方的写队列(RQ)。写到管道一端的数据会被放置到另一个的读队列里。
因为一个STREAMS管道是一个流,我们可以把一个STREAMS模块推到管道的任一端来处理写到管道的数据(如下图所示)。但是如果我们把一个模块推到某端,则我们不能在另一端弹出它。如果我们想要删除它,我们需要在我们压入它的那端删除它。
假定我们不做任何特别的事,比如推一个模块,那么一个STREAMS管道和非STREAMS管道的行为一样,除了它支持多数在streamio里描述时STREAMS的ioctl命令。在17.2.2节,我们将看到一个向STREAMS管道推一个模块来提供唯一的连接,当我们在文件系统给管道一个名字时。
让我们用单个STREAMS管道重新实现协进程的例子。下面的代码展示一个新的main函数。add2协进程和15.4节那个是相同的。我们调用一个新的函数,s_pipe,来创建单个STREAMS管道。(我们马上会展示这个函数的STREAMS管道版本和UNIX域套接字版本。)
回想15.1节FreeBSD支持全双工管道,但是这些管道不是基于STREAMS机制的。
我们定义和标准pipe函数相似的函数s_pipe。两个函数都接受相同的参数,但是s_pipe返回的描述符打开来可以同时读写。
下面的代码展示了s_pipe函数的基于STREAMS的版本。这个版本简单地调用创建一个全双工管道的标准pipe函数。
17.2.1 命名STREAMS管道
通常,管道可以只在相关进程之间使用:子进程从父进程继承管道。在15.5节,我们看到了无关进程可以使用FIFO来通信,但是它只提供了单向的通信路径。STREAMS路径为进程提供一种方法在文件系统里给一个管道一个名字。这绕过了处理单向FIFO的问题。
我们可以使用fattach函数来在文件系统给一个STREAMS管道一个名字。
#include
int fattach(int filedes, const char *path);
成功返回0,错误返回-1。
path参数必须引用一个已有的文件,调用进程必须拥有这个文件,或有对它有写的权限,或使用超级用户特权运行。
一旦STREAMS管道被附加到文件系统命名空间上时,底下的文件是不可访问的。任何打开这个名字的进程将得到这个管道的访问,不是底下的文件。事实上,这些进程通常会不知道这个名字现在引用到一个不同的文件。
管道只有一端被附加到文件系统的一个名字。另一端用来和打开这个附加的文件名的进程通信。即使它可以附加任何类型的STREAMS文件到文件系统的一个名字,fattach函数普遍用来给一个STREAMS管道一个名字。
一个进程可以调用fdetach来撤消一个STREAMS文件和文件系统里的名字之间的关联。
#include
int fdetach(const cha *path);
成功返回0,错误返回-1。
在fdetach被调用后,任何通过打开path而有STREAMS管道的访问的进程将仍继续访问这个流,但是后续的path打开会访问在文件系统里的原始文件。
17.2.2 唯一连接
尽管我们能附加一个STREAMS管道的一端到文件系统命名空间,但是如果多个进程想使用命名STREAMS管道和一个服务器通信那么我们仍然有问题。一个客户的数据将和另一个写到这个管道的数据交叉。即使我们保证客户写少于PIPE_BUF的字节以便写是原子的,然而我们没有方法向单个客户写并保证目的客户会读到这个消息。当多个客户从相同的管道上读时,我们不能控制哪个会被调度并读取我们发送的东西。
connld STREAMS模块解决了这个问题。在附加一个STREAMS管道到文件系统的一个名字之间,一个服务器进程可以把connld模块推到要被附加的管道那端。这导致下图所示的配置。
在上图,服务器进程已经附加了它的管道的一端到路径/tmp/pipe。我们显示一个虚线来指出一个客户进程正在打开附加的STREAMS管道的过程中。一旦打开完成,我们有下图所示的配置。
客户进程不会接收它打开的管道的一端的文件描述符。相反,操作系统打开一个新的管道并把一端返回给客户进程作为打开/tmp/pipe的结果。系统把这个新管道的另一端发送给服务器进程,通过在已有(附加的)管道传递它的文件描述符,导致在客户进程和服务进程之间的唯一连接。我们将在17.4.1节看到使用STREAMS管道传递文件描述符的机制。
fattach函数在mount系统调用之上建立。这个设置被称为挂载的流。挂载的流和connld模块由Presotto和Richie[1990]为Research UNIX系统开发。这些机制然后被SVR4挑选。
我们现在将开发三个可以在无关进程之间创建唯一连接的函数。这些函数效仿16.4节讨论的面向连接的套接字函数。我们这里使用STREAMS管道作为底下的通信机制,但是我们将在17.3节看到使用UNIX域套接字的这些函数的另一种的实现。
#include "apue.h
int serv_listen(const char *name);
成功返回要监听的文件描述符,错误返回负值。
int serv_accept(int listenfd, uid_t *uidptr);
成功返回新的文件描述符,错误返回负值。
int cli_conn(const char *name);
成功返回文件描述符,错误返回负值。
下面的代码实现了serv_listen函数,可以被一个服务器用来声明它想在一个熟知名(文件系统里的一些路径名)上监听客户的连接请求。当他们想连接这个服务器时客户将使用这个名字。返回值是STREAMS管道的服务器端。
17.3 UNIX域套接字(UNIX Domain Sockets)
UNIX域套接字被用来和同一机器上运行的进程通信。尽管因特网域套接字可以用作同样的目的,然而UNIX域套接字更高效。UNIX域套接字只拷贝数据;它们没有要执行的协议处理,没有要增加或删除的网络头,没有要计算的校验和,没有要产生的序列号,没有要发送的确认信息。
UNIX域套接字同时提供了流和数据报接口。尽管如此,UNIX域数据报服务是可靠的。消息不会被丢失也不会乱序。UNIX域套接字像在套接字和管道之间的过渡。你可以使用面向网络的套接字接口来使用它们,或者你可以使用socketpair函数然创建一对没有名字的、连接的UNIX域套接字。
#include
int socketpair(int domain, int type, int protocol, int sockfd[2]);
成功返回0,错误返回-1。
尽管接口是充分通用的,允许socketpair在任何域里使用,但是操作系统典型地只提供对UNIX域的支持。
下面的代码展示了前面17.2节里的s_pipe函数的基于套接字的版本。这个函数创建了一对已连接的UNIX域流套接字。
17.3.1 命名UNIX域套接字(Naming UNIX Domain Sockets)
尽管socketpair函数创建了和对方相连的套接字,但是这个套接字没有名字。这意味着它们不能被无关进程寻址。
在16.3.4节,我们学到了如何把一个地址绑定到一个因特网域套接字上。正如因特网域套接字一样,UNIX域套接字可以被命令并用来宣传服务。然而,UNIX域使用的地址格式和因特网域套接字不同。
回想下16.3节套接字地址格式在不同的实现上都会有所区别。一个UNIX域套接字的地址由一个sockaddr_un结构体表示。在Linux2.4.22和Solaris 9上,sockaddr_un结构全被定义在头文件
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* pathname */
};
然而,在FreeBSD 5.2.1和Mac OS X10.3上,sockaddr_un结构体被定义为:
struct sockaddr_un {
unsigned char sun_len; /* length including null */
sa_family_t sun_family; /* AF_UNIX */
char sun_path[104]; /* pathname */
};
sockaddr_un的结构体sun_path成员包含一个路径名。当我们把一个地址绑定到一个UNIX域套接字时,系统用相同的名字创建一个类型为S_IFSOCK的文件。
这个文件的存在只做为对客户宣传套接字名的一种方式。这个文件不能被打开或被应用为通信的其它使用。
当我们尝试绑定到相同的地址时如果文件已经存在,bind请求将会失败。当我们关闭套接字时,这个文件会自动被删除,所以我们在程序退出时需要确保我们反链接了它。
下面的程序展示了绑定一个地址到一个UNIX域套接字的一个例子。
17.3.2 唯一连接(Unique Connections)
一个服务器可以使用bind、listen和accept函数来安排唯一的到客户的UNIX域连接。客户使用connect来联系服务器;在连接请求被服务器接受后,在客户和服务器之间有唯一的连接。这种操作风格和我们在16.5节里演示的因特网域套接字相同。
下面的代码展示了serv_listen函数的UNIX域套接字版本。
首先,我们通过调用socket创建单个UNIX域套接字。我们然后填充一个sockaddr_un结构体,把熟知的路径名赋到这个套接字上。这个结构体是bind的参数。注意我们不需要设置在一些平台上出现的sun_len域,因为操作系统使用我们传给bind函数的地址长度为我们设置了这个。
最后,我们调用listen(16.4节)来告诉内核进程将扮演一个服务器,等待客户的连接。当一个客户的一个连接请求到达时,服务器调用serv_accept函数(下面的代码)。
服务器阻塞在accept的调用里,等待一个客户调用cli_conn。当accept返回时,它的返回值是一个和客户连接的全新的描述符。(这和connld模块在STREAMS子系统上做的事有些相似。)此外,客户赋到它套接字上的路径名(包含客户进程ID的那个名字)被由accept返回,通过第二个参数(指向sockaddr_un结构体的指针)。我们使路径名空字符终止并调用stat。这让我们验证路径名确实是一个套接字且和这个套接字相关的三个时间都不老于30秒。(回想6.10节time函数返回自Epoch至今的秒数。)
如果三个检查都成功,那么我们假定客户的标识(它的用效用户ID)是套接字的属主。尽管这个检查并不完美,但是它是在当前系统上我们能做的最好的了。(如果内核返回有效用户ID给accept,就像I_RECVFD ioctl命令做的那样,那就更好了。)
客户通过调用cli_conn函数来初始化到服务器的连接。如下面的代码所示。
我们不让系统选择一个默认的地址,因为服务器会不能把一个客户和另一个区分开。事实上,我们绑定自己的地址,一个当开发使用套接字的客户程序时通常不会采取的步骤。
我们绑定的路径名的最后最后五个字符由客户的进程ID组成。我们调用unlink,只是防止路径名已经存在。我们然后调用bind来给客户的套接字赋一个名字。这在文件系统里用和被绑定的路径路径名相同的名字创建一个套接字文件。我们调用chmod来关闭除了用户读、用户写和用户执行之外的所有权限。在serv_accept里,服务器检查这些权限和套接字的用户ID来检查客户的身份。
我们然后必须填充另一个sockaddr_un结构体,这将是服务器的被熟知的路径名。最后,我们调用connect函数来初始化和服务器的连接。
17.4 传递文件描述符(Passing File Descriptors)
在进程间传递一个打开的文件描述符的能力是非常强大的。它可以导向不同的设计C/S应用的方法。它允许一个进程(通常是一个服务器)来做被请求的所有事来打开一个文件(涉及诸如翻译一个网络名到一个网络地址、对猫拨号、为文件拿到锁,等等)并简单地把一个可以和所有I/O函数一起使用的描述符传递回给调用进程。所有打开这个文件或设备所涉及的细节都对客户隐藏。
我们必须更明确我们说从一个进程向另一个“传递一个打开的文件描述符”的意思。回想3.10节里的图,它展示两个打开相同文件的进程。尽管它们共享同一个v-node,但是每个进程有它们自己的文件表项。
当我们从一个进程向另一个传递一个打开的文件描述时,我们想传递进程和招收进程都共享相同的文件表项。
技术上,我们正把一个指向一个打开的文件表项的指针从一个进程传递到另一个里。这个指针被赋成接收进程里的第一个可用描述符。(说我们正传递一个打开的描述符误导我们以为接收进程的描述符号和发送进程的相同,但通常不是这样。)让两个进程共享一个打开的文件表正是在fork之后所发生的事。
当一个描述符从一个进程传递到另一个时发生的事是,发送进程在传递完描述符后然后关闭这个描述符。被发送者关闭的描述符并不真正关闭文件或设备,因为描述符在接收进程里仍视为打开的(即使接收者还没有明确地收到这个描述符。)
我们定义以下的三个函数,我们在本章用它们发送和接收文件描述符。本节稍后,我们将展示这三个函数的代码,包括STREAMS版本和套接字版本。
#include "apue.h"
int send_fd(int fd, int fd_to_send);
int send_err(int fd, int status, const char *errmsg);
成功返回0,错误返回-1。
int recv_fd(int fd, ssize_t (*userfunc)(int, const void *, size_t));
成功返回文件描述符,错误返回负值。
一个想传递一个描述符给另一个进程的进程(通常是一个服务器)调用send_fd或send_err。等待接收描述符的进程(客户)调用recv_fd。
send_fd函数发送描述符fd_to_send,通过fd表示的STREAMS管道或UNIX域套接字。
我们将使用术语s-pipe来引用一个双向通信渠道,它可以由STREAMS管道或UNIX域流套接字实现。
send_err函数使用fd发送errmsg,接着是status字节。status的值必须是-1到-255的范围。
客户调用recv_fd来接收一个描述符。如果成功(发送者调用send-fd),那么这个函数返回非负的描述符。否则返回值是由send_err发送的status(一个-1到-255的负值)。此外,如果一个错误信息被一个服务器发送,那么客户的userfunc被调用来处理这个消息。userfunc的第一个参数是常STDERR_FILENO,接着是指向错误消息的指针和它的长度。userfunc的返回值是写入的字节数据或在错误是的一个负数。经常,客户指定普通的write函数作为userfunc。
我们实现被这三个函数使用的我们自己的协议。为了发送一个描述符,send_fd发送两个0字节,接着是真实的描述符。为了发送一个错误,send_err发送errmsg,接着是一个0字节,再接着是status字节的绝对值(1到255)。recv_fd函数读取在s-pipe上的所有东西,直到它碰到一个空字节。读到这个点为止所得到的所有数据都被传递给调用者的userfunc。从recv_fd读到的下一个字节是status字节。如果status字节为0,一个描述符被返回,否则,没有描述符可接收。
函数send_err调用send_fd函数,在向s-pipe写入错误消息后。如下面的代码所示。
17.4.1 通过基于STREAMS的管道来传递文件描述符(Passing File Descriptors over STREAMS-based Pipes)
通过使用STREAMS管道,文件描述符使用两个ioctl命令来被交换:I_SENDFD和I_RECVFD。为了发送一个描述符,我们把ioctl的第三个参数设为真实的描述符。如下面代码所示:
17.4.2 通过UNIX域套接字来传递文件描述符(Passing File Descriptors over UNIX Domain Sockets)
};
前两个元素通常用来在一个网络连接上发送数据报,而目标地址可以和各个数据报一起指定。接下来两个元素允许我们指定一个缓冲数组(分散读或集合写),正如我们在14.7节描述的readv和writev一样。msg_flags域包含描述收到的消息的标志,如16.5节里的表汇总的一样。
两个元素处理控制信息的发送或接收。msg_control域指向一个cmsghdr(control message header)结构体,而msg_controllen域包含控制信息字节数。
struct cmsghdr {
socklen_t cmsg_len; /* data byte count, including header */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
/* followed by the actual control message data */
};
为了发送一个文件描述符,我们设置cmsg_len为cmsghdr结构体的尺寸加上一个整型(描述符)的尺寸。cmsg_level域被设为SOL_SOCKET,而cmsg_type被设为SCM_RIGHTS,来指明我们正传递访问权利。(SCM代表socket-level control message。)访问权利只可以通过一个UNIX域套接字传递。描述符就存储在cmsg_type域的后面,使用宏CMSG_DATA来得到这个整型的指针。
三个宏被用来访问控制数据,一个宏用来帮助计算用于cmsg_len的值。
#include
unsigned char *CMSG_DATA(struct cmsghdr *cp);
返回和cmsghdr结构体相关的数据的指针。
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *mp);
返回和msghdr结构体相关联的第一个cmsghdr结构体的指针,或者没有一个存在时返回NULL。
struct cmsghdr *CMSG_NXTHDR(struct msghdr *mp, struct cmsghdr *cp);
给定当前cmsghdr结构体,返回和msghdr结构体相关联的下一个cmsghdr结构体的指针,或我们已经在最后一个上时返回NULL
unsigned int CMSG_LEN(unsigned int nbytes);
返回为nbytes大的数据对象分配的尺寸。
CMSG_LEN宏在加上cmsghdr结构体的尺寸、为处理器架构所需的任何对齐限制做的调整、以及往上取约之后,返回存储一个尺寸为nbytes的数据对象所需的字节数。
下面的代码是UNIX域套接字版本的send_fd。
为了接收一个描述符,我们为一个cmsghdr结构体和一个描述符分配足够的空间,设置msg_control来指向被分配的区域,并调用recvmsg。我们使用CMSG_LEN宏来计算需要的空间量。
我们从套接字读,直到我们在最终状态字节前讲到一个空字节。所有在这个空字节之前的东西都是一个从发送者而来的错误消息。看下面的代码。