可以通过关闭termios结构中c_lflag字段的ICANON标志来指定非规范模式。在非规范模式中,输入数据不装配成行,不处理下列特殊字符(见18.3节):ERASE、KILL、EOF、NL、EOL、EOL2、CR、REPRINT、STATUS和WERASE。
如前所述,规范模式很容易理解:系统每次至多返回一行。但在非规范模式下,系统如何知道在什么时候将数据返回给我们呢?如果它一次返回一个字节,那么系统开销就会过大。(回忆图3-6,从中可以看到每次读一个字节的开销有多大。如果每次返回的数据加倍,那么系统调用的开销就可以减半。)在启动读数据之前,往往不知道要读多少数据,所以系统不能总是一次返回多个字节。
解决方法是,当已读了指定量的数据后,或者已经超过了给定量的时间后,即通知系统返回。这种技术使用了termios结构中c_cc数组的两个变量:MIN和TIME。c_cc数组中的这两个元素的下标名为VMIN和VTIME。
MIN指定一个read返回前的最小字节数。TIME指定等待数据到达的分秒数(分秒为秒的1/10)。有下列4种情形。
情形A:MIN>0,TIME>0
TIME指定一个字节间定时器(interbyte timer),它只在第一个字节被接收时启动。
在该定时器超时之前,若已接到MIN个字节,则read返回MIN个字节。如果在
接到MIN 个字节之前,该定时器已超时,则read返回已接收到的字节。(因为定
时器是在第一个字节被接收后启动的,所以在定时器超时时,read 至少会返回一
个字节。)在这种情形中,第一个字节被接收之前,调用者会一直阻塞。如果在调
用read时数据已经可用,则就如同在read后数据被立即接收了一样。
情形B:MIN>0,TIME==0
read在接收到MIN个字节之前不返回。这会造成read无限期阻塞。
情形C:MIN==0,TIME>0
TIME指定一个调用read时启动的读定时器。(与情形A相比较,两者是不同的。
在情形A中,非0 TIME表示字节间定时器,该定时器要等到第一个字节被接收时
才启动。)在接到一个字节或者该定时器超时时,read即返回。如果是定时器超时,
则read返回0。
情形D:MIN==0,TIME==0
如果有数据可用,则 read 最多返回所要求的字节数。如果无数据可用,则 read
立即返回0。
在所有这些情形中,MIN 只是最小值。如果程序要求的数据多于 MIN 个字节,那么它或许能接收到所要求的字节数。这也适用于MIN==0的情形C和情形D。
图18-19总结并列出了非规范模式输入的4种不同情形。在这个图中,nbytes是read的第三个参数(返回的最大字节数)。
图18-19 非规范输入的4种情形
请注意,POSIX.1允许下标VMIN和VTIME的值分别与VEOF和VEOL的相同。确实,Solaris就是这样做的,这样就提供了与System V的早期版本的兼容性。但是,这也带来了可移植性问题。从非规范模式转换为规范模式时,必须恢复VEOF和VEOL。如果VMIN等于VEOF,且不恢复它们的值,那么当把VMIN的典型值设置为1时,文件结束符就变成了Ctrl+A。解决这一问题最简单的方法是:在要转入非规范模式时,将整个termios结构保存起来,以后再要转回规范模式时恢复它。
实例
图18-20中的程序定义了函数tty_cbreak和tty_raw,它们将终端分别设置为cbreak模式(cbreak mode)和原始模式(raw mode)。(术 语cbreak和原始来自于V7的终端驱动程序。) tty_reset函数的功能是将终端恢复到原始的工作状态(也就是调用tty_cbreak或tty_raw之前的工作状态)。
如果已调用 tty_cbreak,那么在调用 tty_raw 之前需要调用 tty_reset。如果已调用tty_raw,然后又要调用 tty_cbreak,那么在此之前同样也要调用 tty_reset。这减少了出错时终端处于不可用状态的机会。
该程序还提供了另外两个函数:tty_atexit和tty_termios。tty_atexit可被登记为退出处理程序,以保证exit 恢复终端工作模式。tty_termios 则返回一个指向原来规范模式下termios结构的指针。
图18-20 将终端模式设置为cbreak模式或原始模式
cbreak模式的定义如下。
•非规范模式。如本节开始处所述,这种模式关闭了对某些输入字符的处理。这种模式没有关闭对信号的处理,所以用户始终可以键入一个能够触发终端产生信号的字符。请注意,调用者应当捕捉这些信号,否则这种信号就有可能终止程序,并且使终端保持在cbreak模式。
作为一般规则,在编写更改终端模式的程序时,应当捕捉大多数信号,以便在程序终止前恢复终端模式。
•关闭回显。
•每次输入一个字节。为此,将MIN设置为1,将TIME设置为0。这是图18-19中的情形B。至少有一个字节可用时,read才返回。
对原始模式的定义如下。
•非规范模式。也关闭了对信号产生字符(ISIG)和扩充输入字符(IEXTEN)的处理。
另外还禁用了BRKINT字符,使BREAK字符不再产生信号。
•关闭回显。
•禁止输入中的CR到NL映射(ICRNL)、输入奇偶检测(INPCK)、剥离输入字节的第8位(ISTRIP)以及输出流控制(IXON)。
•8位字符(CS8),且禁用奇偶校验(PARENB)。
•禁止所有输出处理(OPOST)。
•每次输入一个字节(MIN=1,TIME=0)。
图18-21中的程序测试原始模式和cbreak模式。
图18-21 测试原始终端模式和cbreak终端模式
运行图18-21中的程序,可以观察这两种终端工作模式的工作情况。
$ ./a.out
Enter raw mode characters, terminate with DELETE
4
33
133
61
70
176
键入Delete
Enter cbreak mode characters, terminate with SIGINT
1 键入Ctrl+A
10 键入退格
signal caught 键入中断键
在原始模式中,输入的字符是Ctrl+D(04)和特殊功能键F7。在所用的终端上,此功能键产生5个字符:ESC(033)、[(0133)、1(061)、8(070)和˜(0176)。注意,在原始模式下关闭了输出处理(˜OPOST),所以在每个字符后没有得到回车符。另外还要注意的是,在cbreak模式下,不对输入特殊字符进行处理(因此没对 Ctrl+D、文件结束符和退格进行特殊处理),但是仍对终端产生的信号进行处理。
大多数UNIX系统都提供了一种跟踪当前终端窗口大小的方法,在窗口大小发生变化时,使内核通知前台进程组。内核为每个终端和伪终端都维护了一个winsize结构:
struct winsize {
unsigned short ws_row; /* rows, in characters */
unsigned short ws_col; /* columns, in characters */
unsigned short ws_xpixel; /* horizontal size, pixels (unused) */
unsigned short ws_ypixel; /* vertical size, pixels (unused) */
};
此结构的规则如下。
•用ioctl(见3.15节)的TIOCGWINSZ命令可以取此结构的当前值。
•用 ioctl 的 TIOCSWINSZ 命令可以将此结构的新值存储到内核中。如果此新值与存储在内核中的当前值不同,则前台进程组会收到SIGWINCH信号。(注意,从图10-1中可以看出,此信号的系统默认动作是被忽略。)
•除了存储此结构的当前值以及在此值改变时产生一个信号以外,内核对该结构不进行任何其他操作。对结构中的值进行解释完全是应用程序的工作。
提供这种功能的目的是,当窗口大小发生变化时应用程序能够得到通知(如vi编辑器)。应用程序接收此信号后,可以获取窗口大小的新值,然后重绘屏幕。
实例
图 18-22 所示的程序打印当前窗口大小,然后休眠。每次窗口大小改变时,程序就捕捉到SIGWINCH信号,然后打印新的窗口大小。我们必须用一个信号终止此程序。
图18-22 打印窗口大小
在一个带窗口终端的系统上运行图18-22中的程序得到:
$ ./a.out
35 rows, 80 columns 初始大小
SIGWINCH received 更改窗口大小:捕捉到信号
40 rows, 123 columns
SIGWINCH received 再一次
42 rows, 33 columns
^C $ 键入中断键以终止
termcap 的意思是终端能力(terminal capability),它涉及文本文件/etc/termcap 和一套读此文件的例程。termcap 这种技术是在伯克利开发的,注意是为了支持 vi 编辑器。termcap文件包含了对各种终端的说明:终端支持哪些功能(如行数、列数、终端是否支持退格),如何使终端执行某些操作(如清屏、将光标移动到给定位置)。把这些信息从编译过的程序中取出来并把它们放在易于编辑的文本文件中,这样就使得vi编辑器能在很多不同的终端上运行。
最后,将支持termcap文件的例程从vi编辑器中抽取出来,放在一个单独的curses库中。为使这套库可供要进行屏幕处理的任何程序使用,还增加了很多功能。
termcap这种技术并不是很完善。当越来越多的终端被加到数据文件中时,为找到一个特定的终端,需要花费更长的时间扫描此数据文件。这个数据文件还用两个字符的名字来标识不同的终端属性。这些缺陷迫使开发人员开发出了 terminfo 以及与其相关的curses库。在terminfo中,终端说明基本上都是文本说明的编译版本,在运行时易于被快速定位。terminfo 最初由SVR2开始使用,此后所有System V的版本都使用它。
历史上,基于System V的系统使用terminfo,BSD派生的系统使用termcap,但是现在,系统通常两者都提供。然而Mac OS X仅支持terminfo。
Goodheart[1991]对terminfo和curses库进行了详细说明,但此书已不再增印。Strang[1986]说明了curses函数库的伯克利版本。Strang、Mui和O’Reilly[1988]则对termcap和terminfo进行了说明。
可在http://invisible-island.net/ncurses/ncurses.html或http://www.gnu. org/software/ncurses上找到与SVR4 curses接口兼容的开放版ncurses函数库。
不论是 termcap 还是 terminfo,它们本身都不处理本章所述及的问题:更改终端的模式、更改终端特殊字符、处理窗口大小等。它们所提供的是在各种终端上执行典型操作(清屏、移动光标)的方法。另一方面,在本章所述问题方面,curses 能提供某种具体细节方面的帮助。curses提供了很多函数,用来设置原始模式、设置cbreak模式、打开和关闭回显等。注意,curses 库是为基于字符的哑终端设计的,而如今,它们大部分已被以基于像素的图形终端所代替。
终端有很多特征和选项,其中大多数都可按需进行更改。本章描述了很多更改终端操作(即更改特殊输入字符和可选择标志)的函数,还介绍了可对终端设备进行设置或恢复的各个终端特殊字符以及众多选项。
终端的输入模式有两种—规范的(每次一行)和非规范的。本章中包含了若干这两种工作模式的实例,也提供了一些函数,它们在POSIX.1终端选项和较早的BSD cbreak模式及原始模式之间进行映射。本章还说明了如何获取和改变终端窗口大小。
18.1 编写一个调用 tty_raw 并且不恢复终端模式就终止的程序。如果系统提供 reset(1)命令(本书说明的4种平台全都提供),使用该命令恢复终端模式。
18.2 c_cflag字段的PARODD标志允许我们设置奇检验或偶校验,而BSD中的tip程序也允许奇偶校验位为0或1。它是如何实现的?
18.3 如果你系统中的stty(1)命令输出MIN和TIME值,做下面的练习。登录系统两次,其中一次登录时打开vi编辑器,在另外一次登录中用stty命令确定vi设置的MIN和TIME值(因为vi将终端设置为非规范模式)。(如果你的终端上有窗口系统正在运行,那么你也可以进行同样的测试,方法是:登录一次,然后用两个分开的窗口。)
在第9章中,我们了解到,终端登录是经由自动提供终端语义的终端设备进行的。在终端和运行程序之间有一个终端行规程(见图18-2),通过该规程我们能够设置终端的特殊字符(如退格、行删除、中断等)。但是,当一个登录请求到达网络连接时,终端行规程并不是自动被加载到网络连接和登录shell之间的。图9-5显示了一个伪终端(pseudo terminal)设备驱动程序,用于提供终端语义。
伪终端除了用于网络登录,还有其他用途,本章将对此进行介绍。首先概要叙述如何使用伪终端,接着讨论某些特殊使用情况。然后,提供在多种平台下用于创建伪终端的函数,并使用这些函数编写一个程序,我们将该程序称为pty。将看到pty程序的各种用途:抄录在终端上输入和输出的所有字符(script(1)程序);运行协同进程来避免图15-19中的程序遇到的缓冲区问题。
伪终端这个术语是指,对于一个应用程序而言,它看上去像一个终端,但事实上它并不是一个真正的终端。图 19-1 显示了使用伪终端时,相关进程的典型安排。图中的关键点如下。
图19-1 使用伪终端的相关进程的典型结构
•通常,一个进程打开伪终端主设备,然后调用 fork。子进程建立一个新的会话,打开一个相应的伪终端从设备,将其文件描述符复制到标准输入、标准输出和标准错误,然后调用exec。伪终端从设备成为子进程的控制终端。
•对于伪终端从设备上的用户进程来说,其标准输入、标准输出和标准错误都是终端设备。通过这些描述符,用户进程能够处理第 18 章中的所有终端 I/O 函数。但是因为伪终端从设备不是真正的终端设备,所以无意义的函数调用(例如,改变波特率、发送中断符、设置奇偶校验)将被忽略。
•任何写到伪终端主设备的都会作为从设备的输入,反之亦然。事实上,所有从设备端的
输入都来自于伪终端主设备上的用户进程。这看起来就像一个双向管道,但从设备上的终端行规程使我们拥有普通管道没有的其他处理能力。
图19-1显示了FreeBSD、Mac OS X或Linux系统中的伪终端结构。19.3 节将介绍如何打开这些设备。
在Solaris中,伪终端是使用STREAMS子系统构建的(见14.4节)。图19-2详细描述了Solaris中各个伪终端STREAMS模块的安排。虚线框中的两个 STREAMS 模块是可选的。pckt 和 ptem 模块帮助提供伪终端特有的语义。另外两个模块(ldterm 和 ttcompat)提供行规程处理。19.3 节将展示如何建立这些STREAMS模块的安排。
现在简化以上图示,不再画出图 19-1 中的“读函数和写函数”或图19-2中的“流首”。同时使用缩写“PTY”表示伪终端,并将图 19-2中所有伪终端从设备之上的 STREAMS 模块合并在一起表示为“终端行规程”模块,像图19-1中的那样。
图19-2 Solaris中的伪终端安排
现在,我们来考察伪终端的某些典型用途。
1.网络登录服务器
伪终端可用于构造提供网络登录的服务器。典型的例子是 telnetd 和 rlogind 服务器。Stevens[1990]中的第15章详细讨论了提供rlogin服务的步骤。一旦登录shell运行在远端主机上,即可得到图19-3中所示的安排。telnetd服务器使用类似的安排。
在rlogind服务器和登录shell之间有两个exec调用,这是因为login程序通常是在两个exec之间检验用户是否合法。
图19-3的一个关键点是,驱动PTY主设备的进程通常同时在读写另一个I/O流。本例中另一个I/O流是TCP/IP框。这表示该进程必然使用了某种形式的诸如select或poll这样的I/O多路转接(见14.4节),或者被分成两个进程或线程。
2.窗口系统终端模拟
窗口系统通常提供一个终端模拟器,这样我们就能在熟悉的命令行环境中通过 shell 来运行程序。终端模拟器作为shell和窗口管理器之间的媒介。每个shell在自己的窗口中执行。这个安排(两个shell运行在不同窗口)如图19-4所示。
shell将自己的标准输入、标准输出、标准错误连接到PTY的从设备端。终端模拟器程序打开PTY的主设备。终端模拟器除了作为窗口子系统的接口,还要负责模拟一种特殊的终端,这意味着它需要根据它所模拟的设备类型来响应返回码。这些码列在termcap和terminfo数据库中。
图19-3 rlogind服务器的进程安排
图19-4 窗口系统的进程安排
当用户改变终端模拟器窗口的大小时,窗口管理器会通知终端模拟器。终端模拟器在PTY的主设备端发出TIOCSWINSZ ioctl命令来设置从设备的窗口大小。如果新的窗口大小和当前的不同,内核会发送一个SIGWINCH信号给前台PTY从设备的进程组。如果应用程序在窗口大小改变时需要重绘屏幕,它就会捕捉这个SIGWINCH信号,然后发出TIOCSWINSZ ioctl命令获得新的屏幕尺寸并重绘屏幕。
3.script程序
script(1)程序是随大多数 UNIX 系统提供的,它将终端会话期间的所有输入和输出信息复制到一个文件中。为完成此工作,该程序将自己置于终端和一个新调用的登录shell之间。图19-5详细描述了script程序有关的交互。这里要特别指出,script程序通常是从登录shell启动的,该shell还要等待script程序的终止。
图19-5 script程序
script程序运行时,位于PTY从设备上的终端行规程的所有输出都将复制到脚本文件中(通常称为typescript)。因为击键通常由该行规程模块回显,所以该脚本文件也包括了输入的内容。但是,因为键入的口令不会回显,所以该脚本文件不会包含口令。
在编写本书第1版时,Rich Stevens用script程序获取实例程序的输出。这样避免了手工复制程序输出可能带来的错误。但是,使用script的不足之处是必须处理脚本文件中的控制字符。
在19.5节开发了通用的pty程序后,我们将看到使用pty程序和一个简单的shell脚本就能够实现一个新版本的script程序。
4.expect程序
伪终端可以用来在非交互模式中驱动交互式程序的运行。许多硬连线程序需要一个终端才能运行,passwd(1)命令就是一个例子,它要求用户在系统提示后输入口令。
为了支持批处理操作模式而修改所有交互式程序是非常麻烦的,与这种处理相比,一个更好的解决方法是通过一个脚本来驱动交互式程序。expect程序[Libes 1990, 1991, 1994]提供了这样的方法。类似于19.5节的pty程序,它使用伪终端来运行其他程序。并且,expect还提供了一种编程语言用于检查运行程序的输出,以确定用什么作为输入发送给该程序。当一个源自脚本的交互式的程序正在运行时,不能仅仅是将脚本中的所有内容复制到程序中去,或者将程序的输出送至脚本,而是必须要向程序发送某个输入,检查它的输出,并决定下一步发送给程序的内容。
5.运行协同进程
在图15-19所示的协同进程的例子中,我们不能调用使用标准I/O库进行输入、输出的协同进程,这是因为当通过管道与协同进程进行通信时,标准I/O库会完全缓冲标准输入和标准输出,从而引起死锁。如果协同进程是一个已经编译的程序而我们又没有源程序,则无法在源程序中加入fflush语句来解决这个问题。图15-16显示了一个进程驱动协同进程的情况。我们需要做的是将一个伪终端放到两个进程之间(如图19-6所示),诱使协同进程认为它是由终端驱动的,而非另一个进程。
图19-6 用伪终端驱动一个协同进程
现在协同进程的标准输入和标准输出就像终端设备一样,所以标准I/O库会将这两个流设置成行缓冲。
父进程有两种方法在自身和协同进程之间获得伪终端。(这种情况下的父进程可以类似图15-18中的程序,使用两个管道和协同进程进行通信。)一个方法是,父进程直接调用pty_fork函数(见19.4节)而不是调用fork。另一种方法是,exec该pty程序(见19.5节),将协同进程作为参数。我们将在给出pty程序后介绍这两种方法。
6.观看长时间运行程序的输出
使用任何一个标准shell,可以将一个需要长时间运行的程序放到后台运行。但是,如果将该程序的标准输出重定向到一个文件,并且它产生的输出又不多,那么我们就不能方便地监控程序的进展,因为标准I/O库将完全缓冲它的标准输出。我们看到的将只是标准I/O库函数写到输出文件中的成块输出,有时甚至可能是长度为8 192字节的数据块。
如果有源程序,则可以加入fflush调用强制标准I/O缓冲区在某些节点冲洗或者把缓冲模式改成使用setvbuf的行缓冲。然而,如果没有源程序,可以在pty程序下运行该程序,让标准I/O库认为标准输出是终端。图19-7显示了这个安排,我们将这个缓慢输出的程序称为slowout。从登录shell到pty进程的fort/exec箭头是用虚线表示的,为的是强调pty进程是作为后台任务运行的。
图19-7 使用伪终端运行一个缓慢输出的程序
PTY表现得就像物理终端设备一样,因此应用程序就无须在意它们在使用的是何种设备。然而,在打开PTY设备文件时,应用程序并不需要设置O_TTY_INIT标识。Single UNIX Specification已经要求 PTY 从设备端第一次被打开的时候要初始化,这样该设备正常工作所需要的所有非标准termios标识就都被设置了。这个要求旨在允许PTY设备和遵循POSIX的调用tcgetattr和tcsetattr的应用程序正确地运行。
各种平台打开伪终端设备的方法有所不同。在Single UNIX Specification的XSI扩展中包含了很多函数,试图统一这些方法。这些函数的基础是SVR4用于管理基于STREAMS的伪终端的一组函数。posix_openpt函数提供了一种可移植的方法来打开下一个可用伪终端主设备。
#include <stdlib.h>
#include <fcntl.h>
int posix_openpt(int oflag);
返回值:若成功,返回下一个可用的PTY主设备文件描述符;若出错,返回-1
参数oflag是一个位屏蔽字,指定如何打开主设备,它类似于open(2)的oflag参数,但是并不支持所有打开标志。对于posix_openpt,可以指定O_RDWR来打开主设备进行读、写,指定O_NOCTTY来防止主设备成为调用者的控制终端。其他打开标志都会导致未定义的行为。
在伪终端从设备可用之前,它的权限必须设置,以便应用程序可以访问它。grantpt 函数提供这样的功能:它把从设备节点的用户ID设置为调用者的实际用户ID,设置其组ID为一非指定值,通常是可以访问该终端设备的组。权限被设置为:对个体所有者是读/写,对组所有者是写(0620)。
实现通常将PTY从设备的组所有者设置为tty组。把那些要对系统中所有活动终端具有写权限的程序(如wall(1)和write(1))的设置组ID设置为tty组。因为在PTY从设备上tty组的写权限是被允许的,所以这些程序就可以向活动终端写入。
#include <stdlib.h>
int grantpt(int fd);
int unlockpt(int fd);
两个函数的返回值:若成功,返回0;若出错,返回-1
为了更改从设备节点的权限,grantpt可能需要fork并exec一个设置用户ID程序(如在Solaris中是/usr/lib/pt_chmod)。于是,如果调用者捕捉到 SIGCHLD 信号,那么其行为是未说明的。
unlockpt 函数用于准予对伪终端从设备的访问,从而允许应用程序打开该设备。阻止其他进程打开从设备后,建立该设备的应用程序有机会在使用主、从设备之前正确地初始化这些设备。
注意,在grantpt和unlockpt这两个函数中,文件描述符参数是与伪终端主设备关联的文件描述符。
如果给定了伪终端主设备的文件描述符,那么可以用 ptsname 函数找到伪终端从设备的路径名。这使应用程序可以独立于给定平台的某种特定约定而标识从设备。注意,该函数返回的名字可能存储在静态存储中,因此后续的调用可能会覆盖它。
#include <stdlib.h>
char *ptsname(int fd);
返回值:若成功,返回指向PTY从设备名的指针;若出错,返回NULL
图19-8总结了Single UNIX Specification中的伪终端函数,指出了本书讨论的4种平台分别支持哪些函数。
图19-8 XSI伪终端函数
在FreeBSD中,grantpt和unlockpt除了参数验证外不执行任何操作,PTY是通过正确的权限动态地创建出来的。注意,FreeBSD定义O_NOCTTY标志只是为了兼容调用posix_openpt的应用程序。在FreeBSD中打开终端设备并不会引起分配控制终端的副作用,所以O_NOCTTY标志并无作用。
Single UNIX Specification已经改善了此方面的可移植性,但是差距仍然存在。我们提供了两个处理所有这些细节的函数:ptym_open和ptys_open。ptym_open打开下一个可用的PTY主设备,ptys_open打开相应的从设备。
#include "apue.h"
int ptym_open(char *pts_name, int pts_namesz);
返回值:若成功,返回PTY主设备文件描述符;若出错,返回-1
int ptys_open(char *pts_name);
返回值:若成功,返回PTY从设备文件描述符;若出错,返回-1
通常,不直接调用这两个函数,而是由函数 pty_fork(见 19.4 节)调用它们,并且还会fork出一个子进程。
ptym_open函数打开下一个可用的PTY主设备。调用者必须分配一个数组来存放主设备或从设备的名字,并且如果调用成功,相应的从设备名会通过pts_name返回。然后,这个名字传给用来打开该从设备的ptys_open函数。缓冲区的字节长度由pts_namesz传送,使得ptym_open函数不会复制比该缓冲区长的字符串。
在说明pty_fork函数之后,提供两个函数来打开这两个设备的原因将会很明显。通常,一个进程调用ptym_open来打开一个主设备并且得到从设备名。该进程然后fork子进程,子进程在调用setsid建立新的会话后调用ptys_open打开从设备。这就是从设备如何成为子进程控制终端的过程(见图19-9)。
图19-9 伪终端打开函数
ptym_open函数用XSI PTY函数找到并打开一个未被使用的PTY主设备,并初始化对应的PTY从设备。ptys_open函数打开的是PTY从设备。然而在Solaris系统中,在PTY从设备表现得像个终端前,我们可能需要多做几步工作。
在Solaris中,打开从设备后,我们可能需要将3个STREAMS模块压入从设备的流中。伪终端仿真模块(ptem)和终端行规程模块(ldterm)合在一起像一个真正的终端一样工作。ttcompat提供了对早期系统(如V7、4BSD和Xenix)的ioctl调用的兼容性。这是一个可选的模块,但是因为对于网络登录,它是自动压入的,所以我们将它压入到从设备的流中。
也可能并不需要压入这3个模块,其原因是,它们可能已经位于流中。STREAMS系统支持一种称为autopush(自动压入)的工具,它允许系统管理员配置一张模块列表,只要打开一个特定设备,就将这些模块压入流中(详见Rago[1993])。使用I_FIND ioctl命令观察ldterm是否已在流中。如果是,则认为该流已用autopush机制配置,这样就无需再压入相应模块。
Linux、Mac OS X和Solaris都遵循历史上System V的行为:如果调用者是一个还没有控制终端的会话首进程,这个打开(open)的调用会分配一个PTY从设备作为控制终端。如果不想让这种情况发生,可以在打开(open)时设置O_NOCTTY标志。然而,在FreeBSD中,打开PTY从设备不会产生分配其作为控制终端的副作用,下一节将探讨如何在FreeBSD中分配控制终端。
现在使用上一节介绍的两个函数ptym_open 和ptys_open来编写一个新函数,我们称之为pty_fork。这个新函数具有如下功能:用fork调用打开主设备和从设备,创建作为会话首进程的子进程并使其具有控制终端。
#include "apue.h"
#include <termios.h>
pid_t pty_fork(int *ptrfdm, char *slave_name, int slave_namesz,
const struct termios *slave_termios,
const struct winsize *slave_winsize);
返回值:子进程中返回0;父进程中返回子进程的进程ID;若出错,返回−1
PTY主设备的文件描述符通过ptrfdm指针返回。
如果slave_name不为空,从设备名被存储在该指针指向的存储区中。调用者必须为该存储区分配空间。
如果指针slave_termios不为空,则系统使用该指针所引用的结构初始化从设备的终端行规程。如果该指针为空,那么系统将会把从设备的termios结构设置成实现定义的初始状态。类似地,如果slave_winsize指针不为空,那么按该指针所引用的结构初始化从设备的窗口大小。如果该指针为空,winsize结构通常被初始化为0。
图19-10显示了该函数的代码。它调用相应的ptym_open和ptys_open函数,在本书讨论的4种平台上,pty_fork函数都能工作。
图19-10 pty_fork函数
在打开PTY主设备后,调用fork。正如前面提到的,子进程先调用setsid建立新的会话,然后才调用ptys_open。当调用setsid时,子进程还不是一个进程组的首进程,因此执行9.5节中列出的3个操作步骤:(a)子进程创建一个新的会话,它是该会话的首进程;(b)子进程创建一个新的进程组;(c)子进程断开与以前可能有的控制终端的关联,于是不再有控制终端。在Linux、Mac OS X和Solaris系统中,当调用ptys_open时,从设备成为新会话的控制终端。在FreeBSD系统中,必须调用TIOCSCTTY ioctl来分配一个控制终端。(回想图9-8,其他3个平台也支持TIOCSCTTY ioctl命令,但是只有在FreeBSD中需要我们去调用它。)
termios和winsize这两个结构在子进程中初始化。最后从设备的文件描述符被复制到子进程的标准输入、标准输出和标准错误中。这意味着不管子进程以后调用exec执行何种程序,它都具有同PTY从设备(其控制终端)联系起来的上述3个描述符。
在调用fork后,父进程返回PTY主设备的描述符以及子进程的进程ID。下一节将在pty程序中使用pty_fork函数。
编写pty程序的目的是用
pty prog arg1 arg2
来代替
prog arg1 arg2
当用pty来执行另一个程序时,那个程序在一个它自己的会话中执行,并和一个伪终端连接。
让我们查看pty程序的源代码。第一个文件(见图19-11)包含main函数。它调用上一节的pty_fork函数。
图19-11 pty程序的main函数
下一节介绍pty程序的不同用途时,将看到多种命令行选项。getopt函数帮助我们以协调一致的模式分析命令行参数。为了在Linux系统中强制POSIX行为,我们将选项字符串的第一个字符设置为加号。
在调用pty_fork前,我们获取termios和winsize结构的当前值,将其作为参数传递给pty_fork。通过这种方法,PTY从设备具有和当前终端相同的初始状态。
子进程从pty_fork返回后,可选地关闭了PTY从设备的回显,然后调用execvp来执行命令行指定的程序。所有余下的命令行参数将成为该程序的参数。
父进程可选地将用户终端设置为原始模式。在这种情况下,父进程还要设置退出处理程序,使得在调用exit时复原终端状态。下一节将描述do_driver函数。
接下来,父进程调用函数 loop(见图 19-12),该函数仅仅是将从标准输入接收到的所有内容复制到PTY主设备,并将PTY主设备接收到的所有内容复制到标准输出。尽管使用select或poll的单进程或多线程是可行的,但是为了有所变化,这里使用了两个进程。
图19-12 loop函数
注意,因为使用了两个进程,所以一个终止时,必须通知另一个。我们用 SIGTERM 信号进行这种通知。
接下来看几个pty程序的应用实例,并了解使用不同命令行选项的必要性。
如果使用Korn shell,那么我们执行命令:
pty ksh
会得到一个运行在伪终端下的全新shell。
如果文件ttyname包含了图18-16中所示的程序,那么可按如下模式执行pty程序:
$ who
sar console May 19 16:47
sar ttys000 May 19 16:47
sar ttys001 May 19 16:48
sar ttys002 May 19 16:48
sar ttys003 May 19 16:49
sar ttys004 May 19 16:49 ttys004是当前使用的最高PTY设备
$ pty ttyname 在PTY上运行图18-16中的程序
fd 0: /dev/ttys005 ttys005是下一个可用的PTY
fd 1: /dev/ttys005
fd 2: /dev/ttys005
1.utmp文件
6.8节讨论过记录当前登录到UNIX系统的用户的utmp文件。那么在伪终端上运行程序的用户是否被认为是登录了呢?如果是用telnetd和rlogind远程登录,显然在伪终端上登录的用户应该在utmp文件中有相应记录项。但是,通过窗口系统或script类程序在伪终端上运行shell的用户是否应该在utmp文件中有相应记录项呢?有的系统有记录,有的没有。如果在utmp文件中没有记录的话,who(1)程序一般不会显示相应伪终端正在被使用。
除非utmp文件允许其他用户的写权限(这被认为是一个安全漏洞),否则一般使用伪终端的程序将不能对utmp文件进行写操作。
2.作业控制交互
当在pty下运行作业控制shell时,它能够正常地运行。例如,
pty ksh
将在pty下运行Korn shell。我们能够在这个新shell下运行程序并使用作业控制,这如同在登录shell中一样。但如果在pty下运行一个交互式程序而不是作业控制shell,例如,
pty cat
那么在键入作业控制挂起字符之前该程序的运行一切正常。而在键入作业控制挂起字符时,作业控制挂起字符将会被显示为^Z,并且被忽略。在早期基于 BSD 的系统中,cat 进程终止,pty进程终止,回到初始登录shell。为了明白其中的原因,我们需要检查所有相关的进程以及这些进程所属的进程组和会话。图19-13显示了pty cat运行时的安排。
键入挂起字符(Ctrl+Z)时,它被cat进程下的行规程模块所识别,这是因为pty将终端(在pty父进程之下)设置为原始模式。但内核不会停止cat进程,这是因为它属于一个孤儿进程组(见9.10节)。cat的父进程是pty父进程,它属于另一个会话。
图19-13 pty cat的进程组和会话
历史上,不同的系统处理这种情况的方法也不同。POSIX.1 只是说明 SIGTSTP 信号不能被发送给进程。4.3BSD 的派生系统向进程递送一个它从不捕获的SIGKILL 信号。4.4BSD没有采用发送SIGKILL信号的方法,转而采用符合于POSIX.1的处理方法。如果SIGTSTP信号具有默认配置,并且传递给孤儿进程组中的一个进程,那么4.4BSD的内核会无声息地丢弃SIGTSTP信号。大多数当前的实现都采用这种处理模式。
当我们使用pty来运行作业控制shell时,被这个新shell调用的作业决不会是任何孤儿进程组的成员,这是因为作业控制shell总是属于同一个会话。在这种情况下,键入的Ctrl+Z被发送到由shell调用的进程,而不是shell本身。
让pty调用的进程能够处理作业控制信号的唯一的方法是:另外增加一个pty命令行标志,使pty子进程自己能够识别作业挂起字符(在pty子进程中),而不是让该字符穿越所有路程而到达另一个行规程模块。
3.检查长时间运行程序的输出
另一个使用pty进行作业控制交互的实例见图19-7。如果运行一个缓慢产生输出的程序:
pty slowout > file.out &
当子进程试图从标准输入(终端)读入数据时,pty进程立刻停止运行。这是因为该作业是一个后台作业,并且当它试图访问终端时会使作业控制停止。如果将标准输入重定向使得pty不从终端读取数据,如:
pty slowout < /dev/null > file.out &
那么pty程序也立即停止,因为它从标准输入和终端读取到一个文件结束符。解决这个问题的方法是使用-i选项,这个选项的含义是忽略来自标准输入的文件结束符:
pty -i slowout < /dev/null > file.out &
这个标志导致在遇到文件结束符时,图19-13的pty子进程退出,但子进程不会告诉父进程终止。相反,父进程一直将PTY从设备的输出复制到标准输出(本例中是文件file.out)。
4.script程序
使用pty程序可以把script(1)程序实现成下面shell脚本:
#!/bin/sh
pty "${SHELL:-/bin/sh}" | tee typescript
一旦执行这个shell脚本,即可执行ps命令来观察进程之间的关系。图19-14详细地显示了这些关系。
图19-14 script shell脚本的进程安排
管道
在这个例子中,假设SHELL变量是Korn shell(可能是/bin/ksh)。如前面所述,script仅仅是将新的 shell(和它调用的所有的子进程)的输出复制出来,但是因为 PTY 从设备上的行规程模块通常允许回显,所以绝大多数键入也都被写到typescript文件中。
5.运行协同进程
在图15-8所示的程序中,协同进程不能使用标准I/O函数,其原因是标准输入和标准输出不是终端,所以标准I/O函数会将它们放到缓冲区中。如果把
if (execl("./add2", "add2", (char *)0) < 0)
替换成
if (execl("./pty", "pty", "-e", "add2", (char *)0) < 0)
在pty下运行协同进程,该程序即使使用了标准I/O仍然可以正确运行。
图19-15显示了在使用伪终端作为协同进程的输入和输出时,进程的安排。这是图19-6的扩充,它显示了所有的进程连接和数据流。框中的“驱动程序”是按前面的说明更改了 execl 的图15-8的程序。
这一实例显示了-e(不回显)选项对于pty程序的重要性。因为pty程序的标准输入没有连接到终端,所以它不以交互方式运行。在图 19-11 程序中,interactive 标志默认为假,这是因为对isatty调用的返回是假。这意味着真正终端上的行规程保持在规范模式下,并允许回显。指定-e选项后,关掉了PTY从设备上的行规程模块的回显。如果不这样做,则键入的每一个字符都将被两个行规程模块各回显一次。
图19-15 运行一个协同进程,以伪终端作为其输入和输出
还能用-e选项关闭termios结构的ONLCR标志,以防止所有协同进程的输出被回车和换行符终止。
在不同的系统上测试这个例子,会遇到14.7节中描述readn和writen函数时顺便提到的同样问题。当描述符引用的不是普通磁盘文件时,从read返回的数据量可能会因两个实现之间的不同而有所区别。使用pty的协同进程实例产生了非预期的结果,其原因可追溯至图15-18的程序中读管道的read函数,它返回的结果不足一行。解决方法是不使用图15-18中的程序,而是要使用来自于习题15.5针对这个程序的另外一个版本,这个版本改用标准I/O库,将两个管道的标准I/O流都设置为行缓冲。这样,fgets函数将会读完一个整行。图15-18的程序中的while循环假设发送到协同进程的每一行都会带来一行的返回结果。
6.非交互地驱动交互式程序
虽然让pty运行任意协同进程,甚至交互式的协同进程的想法很诱人,但这是行不通的。问题在于pty只是将其标准输入复制到PTY,并将来自PTY的数据复制到其标准输出,而并不关心具体发送的或得到的是什么数据。
举个例子,我们可以在pty下运行telnet命令,直接与远程主机对话:
pty telnet 192.168.1.3
这样做与直接键入 telnet 192.168.1.3 相比,并没有带来更多的好处,但我们可能希望在一个脚本中运行telnet程序,其目的很可能是要检验远程主机的某个条件。如果telnet.cmd文件包括下面4行:
sar
passwd
uptime
exit
第1行是登录到远程主机时使用的用户名,第2行是口令,第3行是希望运行的命令,第4行终止此会话。如果按下列方式运行此脚本:
pty -i < telnet.cmd telnet 192.168.1.3
那么,它不会像我们所想的那样操作。而是,telnet.cmd文件的内容在还没有得到机会提示我们输入账户名和口令之前,就被发送到了远程主机。当它关闭回显而读口令时,login 使用 tcsetattr 选项,于是丢弃了已在队列中的所有数据。这样一来,我们发送的数据就被丢掉了。
当以交互方式运行telnet程序时,我们等待远程主机发出输入口令的提示,然后再键入口令,但是pty程序不知道这样做。这就是需要一个比pty更巧妙的程序,如expect,从脚本文件驱动交互式程序的原因。
即使如前所示那样从图15-18程序运行pty,这也没有任何帮助。因为图15-18中的程序认为它在一个管道写入的每一行都会在另一个管道产生一行。对于一个交互式程序,输入一行可能产生多行输出。更进一步,图15-18中的程序在从协同进程读之前,它总是先发送一行给该进程。如果想在发送给协同进程一些数据之前从协同进程处读,这种策略就行不通了。
有一些从shell脚本驱动交互式程序的方法。可以在pty上增加一种命令语言和一个解释器。但是一个适当的命令语言可能十倍于pty程序的大小。另一种选择是使用命令语言并用pty_fork函数来调用交互式程序,这正是expect程序所做的。
我们将采用一种不同的途径,使用选项-d使pty程序的输入和输出与驱动进程连接起来。该驱动进程的标准输出是pty的标准输入,反之亦然。这有点像协同进程,只是在pty的“另一边”。此种进程结构与图19-15中所示的几乎相同,只是在这种场景中,由pty来完成驱动进程的fork和exec。而且我们在pty和驱动进程二者之间使用的是一个双向的流管道,而不是两个半双工管道。
图19-16展示的是do_driver函数的源代码,在使用-d选项时,该函数由pty(见图19-11)的main函数调用。
图19-16 pty程序的do_driver函数
通过我们自己编写由pty调用的驱动程序,可以按我们所希望的方式驱动交互式程序。即使驱动程序有和pty连接在一起的标准输入和标准输出,驱动进程仍然可以通过读、写/dev/tty同用户交互。这个解决方法仍不如expect程序通用,但是它用不到50行的代码提供了pty的一种实用的选项。
伪终端还有其他特性,我们在这里简略提一下。Sun Microsystems[2002]和BSD pts(4)的手册页对此有更详细的说明。
1.打包模式
打包模式(packet mode)能够使PTY主设备了解到PTY从设备的状态变化。在Solaris系统中,可以通过将STREAMS模块pckt压入PTY主设备端来设置这种模式。图19-2显示了这种可选模块。在FreeBSD、Linux和Mac OS X中,可以用TIOCPKT ioctl命令来设置这种模式。
Solaris和其他平台相比较,具体的打包模式有所不同。在Solaris中,读取PTY主设备的进程必须调用 getmsg 从流首取得消息,这是因为 pckt 模块将一些事件转化成了无数据的STREAMS消息。在其他平台中,每一次对PTY主设备的读操作都会返回带有可选数据的状态字节。
无论实现细节如何,打包模式的目的是,当PTY从设备上的行规程模块出现以下事件时,通知进程从PTY主设备读取数据:读队列被冲洗;写队列被冲洗,输出被停止(如Ctrl+S),输出重新开始,XON/XOFF 流控制被禁用后重新启用,XON/XOFF 流控制被启用后重新禁用。这些事件由rlogin客户进程和rlogind服务器进程使用。
2.远程模式
PTY主设备可以用TIOCREMOTE ioctl命令将PTY从设备设置成远程模式。虽然FreeBSD、Mac OS X 10.6.8和Solaris 10使用同样的命令来启用或禁用这个特性,但是在Solaris中,ioctl的第三个参数是一个整型数,而在Mac OS X中则是一个指向整型数的指针。(FreeBSD 8.0和Linux 3.2.0不支持这一命令。)
当PTY主设备将PTY从设备设置成这种模式时,它通知PTY从设备上的行规程模块对从主设备接收到的任何数据都不进行任何处理,不管从设备 termios 结构中的规范或非规范标志是否设置,都是这样。远程模式适用于窗口管理器这种进行自己的行编辑的应用程序。
3.窗口大小变化
PTY主设备上的进程可以用TIOCSWINSZ ioctl命令来设置从设备的窗口大小。如果新的大小和当前的大小不同,SIGWINCH信号将被发送到PTY从设备的前台进程组。
4.信号发生
读、写PTY主设备的进程可以向PTY从设备的进程组发送信号。在Solaris 10中,可以用TIOCSIGNAL ioctl命令做到这一点。在FreeBSD 8.0、Linux 3.2.0和 Mac OS X 10.6.8中,用TIOCSIG ioctl来做到这一点。在这两种情况下,第三个参数都是信号编号值。
本章开始部分简要叙述了如何使用伪终端,并观察了某些应用实例。接着,分析说明了在本书讨论的4种平台上打开伪终端所需的代码。然后用此代码提供了通用pty_fork函数,它可用于多种不同的应用。该函数是小程序(pty)的基础,我们使用这一程序揭示了伪终端的许多属性。
伪终端在大多数UNIX系统中每天都被用来进行网络登录。我们还检查了伪终端的许多其他用途,从script程序到使用批处理脚本来驱动交互式程序等。
19.1 当用telnet或rlogin远程登录到一个BSD系统上时,像我们在19.3节讨论过的那样, PTY从设备的所有权和权限被设置。该过程是如何发生的?
19.2 使用pty程序来确定你的系统用于初始化PTY从设备的termios结构和winsize结构的值。
19.3 重写loop函数(见图19-12),使之成为使用select或poll的单个进程。
19.4 在子进程中,pty_fork返回后,标准输入、标准输出和标准错误都以读写模式打开。能够将标准输入变成只读,另两个变成只写吗?
19.5 在图19-13中,指出哪些进程组是前台的,哪些进程组是后台的,并指出会话首进程。
19.6 在图19-13中,当键入文件终止符时,进程终止的顺序是什么?如果可能的话,用进程会计信息验证之。
19.7 script(1)程序通常在输出文件头增加一行说明它的开始时间,在输出文件末尾增加一行说明它的结束时间。将这些特性添加到本章展示的简单的shell脚本中。
19.8 解释为什么在下面的例子中,即使程序 ttyname(见图 18-16)只产生输出而不读入的情况下,文件data的内容还被输出到终端上。
$ cat data 一个两行的文件
hello,
world
$ pty -i < data ttyname -i -i表示忽略stdin的文件结束标志
hello, 这两行来自何处?
world
fd 0:/dev/ttys005 我们期望ttyname输出这3行
fd 1:/dev/ttys005
fd 2:/dev/ttys005
19.9 编写一个调用pty_fork 的程序,该程序有一个子进程,该子进程exec另一个你写的程序。子进程exec的新程序能够捕获SIGTERM和SIGWINCH。当捕获到信号时,要打印出有关消息,并且对于后一种信号,还要打印终端窗口大小。然后让父进程用 19.7 节描述过的ioctl命令向PTY从设备的进程组发送SIGTERM信号。从PTY从设备读回消息并验证捕获到了该信号。接下来由父进程设置PTY从设备窗口的大小,并再读回PTY从设备的输出。让父进程退出(exit)并确定PTY从设备进程是否也要终止;如果要终止,应如何终止?
20世纪80年代早期,UNIX系统被认为不适合运行多用户数据库系统(见Stonebraker[1981]和Weinberger[1982])。早期的系统(如V7),因为没有提供任何形式的IPC机制(除了半双工管道),也没有提供任何形式的字节范围锁机制,所以确实不适合运行多用户数据库系统。但是,这些缺陷中的大多数都已得到纠正。到20世纪了80年代后期,UNIX系统已为运行可靠的、多用户的数据库系统提供了一个适合的环境。自那时以来,很多商业公司都已提供这种数据库系统。
本章将开发一个简单的、多用户数据库的C函数库。调用此函数库提供的C语言函数,其他程序可以获取和存储数据库中的记录。(这类数据库通常被称为键-值存储。)这 个 C 函数库只是一个完整的数据库系统的一部分,我们并不开发其他部分(如查询语言等),关于其他部分可以参阅专门介绍数据库的教科书。我们感兴趣的是数据库函数库与UNIX的接口,以及这些接口与前面各章节所涉及主题的关系(如14.3节的字节范围锁)。
dbm(3)是一个在UNIX系统中很流行的数据库函数库,它由Ken Thompson开发,使用了动态散列结构。最初,它与V7一起提供,并出现在所有BSD版本中,也包含在SVR4的BSD兼容函数库中[AT&T 1990c]。BSD的开发者扩充了dbm函数库,并将它称为ndbm。ndbm函数库包括在BSD和SVR4中。ndbm函数是Single UNIX Specification的XSI扩展标准的一部分。
Seltzer和Yigit[1991]中详细介绍了dbm函数库使用的动态散列算法的历史,以及这个库的其他实现方法,如dbm函数库的GNU版本gdbm。但是,这些实现的一个根本限制是它们都不支持多个进程对数据库的并发更新。它们都没有提供并发控制(如记录锁机制)。
4.4BSD提供了一个新的库——db(3),该库支持3种不同的访问模式:面向记录、散列和B树。同样,db也没有提供并发控制(这一点在db(3)手册页的BUGS部分说得很清楚)。
Oracle(http://www.oracle.com)提供了几个版本的 db 函数库,它们支持并发访问、锁机制和事务。
大部分商用数据库函数库提供多进程同时更新数据库所需要的并发控制。这些系统一般都使用14.3节中介绍的建议记录锁机制,但是,它们也常常实现自己的锁原语,以避免为获得一把无竞争锁而需的系统调用开销。这些商用系统通常用B+树[Comer 1979]或某种动态散列技术,如线性散列[Litwin 1980]或者可扩展的散列[Fagin et al. 1979]来实现数据库。
图20-1列出了本书说明的4种操作系统常用的数据库函数库。注意在Linux上,gdbm库既支持dbm函数库,又支持ndbm函数库。
图20-1 多种平台支持的数据库函数库
本章开发的函数库类似于ndbm函数库,但增加了并发控制机制,从而允许多进程同时更新同一数据库。本节将首先描述数据库函数库的C语言接口,下一节再讨论其实现。
当打开一个数据库时,通过返回值得到一个代表数据库的句柄(一个不透明指针)。将用此句柄作为参数来调用其他数据库函数。
#include "apue_db.h"
DBHANDLE db_open(const char *pathname, int oflag, ... /* int mode */);
返回值:若成功,返回数据库句柄;若失败,返回NULL
void db_close(DBHANDLE db);
如果db_open成功返回,则将建立两个文件:pathname.idx和pathname.dat,pathname.idx是索引文件,pathname.dat是数据文件。参数oflag作为传递给open(见3.3节)的第二个参数,来指定这些文件的打开模式(只读、读/写或如果文件不存在则创建等)。如果需要建立新的数据库,mode将作为第三个参数传递给open(文件访问权限)。
当不再使用数据库时,调用db_close来关闭数据库。db_close将关闭索引文件和数据文件,并释放数据库使用过程中分配到的所有用于内部缓冲区的存储空间。
当向数据库中存入一条新的记录时,必须提供一个此记录的键,以及与此键相关联的数据。如果此数据库存储的是人事信息,键可以是员工ID,数据可以是此员工的姓名、地址、电话号码以及受聘日期等。实现要求每条记录的键必须是唯一的(例如,不会有两个员工记录有同样的员工ID)。
#include "apue_db.h"
int db_store(DBHANDLE db, const char *key, const char *data, int flag);
返回值:若成功,返回0;若出错,返回非0值(见下)
key和data是由null字符终止的字符串。它们可以包含除了null字符外的任何字符,如换行符。
flag参数只能是DB_INSERT(插入一条新记录)、DB_REPLACE(替换一条已有的记录)或DB_STORE(插入一条新记录或替換一条已有的记录,只要合适无论哪一种都可以)。这3个常数定义在apue_db.h 头文件中。如果使用 DB_INSERT 或DB_STORE,并且记录并不存在,则插入一条新记录。如果使用DB_REPLACE或DB_STORE,并且该记录已经存在,则用新记录替换已有记录。如果使用DB_REPLACE,而记录不存在,则将errno设置为ENOENT,返回值为−1,并且不加入新记录。如果使用 DB_INSERT,而记录已经存在,则不插入新记录,返回值为 1。在这里,返回1以区别于一般的出错返回(−1)。
通过指定键key可以从数据库中获取一条记录。
#include "apue_db.h"
char *db_fetch(DBHANDLE db, const char *key);
返回值:若成功,返回指向数据的指针;若没有找到记录,返回NULL
如果找到了记录,返回指向通过key存放的数据的指针。通过指定key,也可以在数据库中删除一条记录。
#include "apue_db.h"
int db_delete(DBHANDLE db, const char *key);
返回值:若成功,返回0;若没有找到记录,返回−1
除了通过指定key获取记录外,还可以逐条记录地访问数据库。为此,首先调用db_rewind回滚到数据库的第一条记录,然后在每一次循环中调用db_nextrec,顺序地读每条记录。
#include "apue_db.h"
void db_rewind(DBHANDLE db);
char *db_nextrec(DBHANDLE db, char *key);
返回值:若成功,返回指向数据的指针;若到达数据库文件的尾端,返回NULL
如果key是非空指针,db_nextrec将这个指针复制到存储区域开始的内存位置,然后返回这个指针。
db_nextrec不保证其返回记录的顺序,只保证对数据库中的每一条记录只读取一次。如果顺序存储3条键分别为A、B、C的记录,则无法确定db_nextrec将按什么顺序返回这3条记录。它可能按B、A、C的顺序返回,也可能按其他顺序。实际的顺序由数据库的实现决定。
这7个函数提供了数据库函数库的接口。接下来介绍实现。
访问数据库的函数库通常使用两个文件来存储信息:一个索引文件和一个数据文件。索引文件包括实际的索引值(键)和一个指向数据文件中对应数据记录的指针。有许多技术可用来组织索引文件以提高按键查询的速度和效率,散列表和 B+树是两种常用的技术。我们采用固定大小的散列表来组织索引文件结构,并采用链表法解决散列冲突。在介绍 db_open 时,曾提到将创建两个文件:一个以.idx为后缀的索引文件和一个以.dat为后缀的数据文件。
我们将键和索引以null结尾的字符串形式存储,它们不能包含任意的二进制数据。有些数据库系统用二进制形式存储数值数据(如用1个、2个或4个字节存储一个整数)以节省存储空间,这样一来使函数复杂化,也使数据库文件在不同的平台间移植比较困难。例如,网络上有两个系统使用不同的二进制格式存储整数,如果想要这两个系统都能够访问数据库,就必须解决不同存储格式的问题(今天不同体系结构的系统在网络上共享文件已经很常见了)。按照字符串形式存储所有的记录,包括键和数据,能使这一切变得简单。这确实需要使用更多的磁盘空间,但降低了获得可移植性需要付出的代价。
db_store要求对于每个键,只有一条对应的记录。有些数据库系统允许多条记录使用同样的键,并提供方法访问与一个键相关的所有记录。另外,我们只有一个索引文件,这意味着每个数据记录只能有一个键(我们不支持次键)。有些数据库允许一条记录拥有多个键,并且对每一个键使用一个索引文件。当插入或删除一条记录时,要对所有的索引文件进行相应的修改。(一个拥有多个索引的例子是员工库文件。可以将员工 ID 作为键,也可以将员工的社会保险号作为键。由于员工的名字并不保证唯一,所以名字不能作为键。)
图20-2是数据库实现的基本结构。
图20-2 索引文件和数据文件结构
索引文件由3部分组成:空闲链表指针、散列表和索引记录。图20-2中,所有指针字段中实际存储的是ASCII码数字形式的文件偏移量。
当给定一个键,要在数据库中寻找一条记录时,db_fetch根据该键计算散列值,由此散列值可确定一条散列链(链表指针字段可以为0,表示一条空的散列链)。沿着这条散列链,可以找到所有具有这一散列值的索引记录。当遇到一个索引记录的链表指针字段为0时,表示到达了此散列链的末尾。
下面来看一个实际的数据库文件。图20-3所示的程序建立了一个新的数据库,并且写入了3条记录。由于所有的字段都以ASCII字符的形式存储在数据库中,所以可以用任何标准的UNIX系统工具来查看索引文件和数据文件:
$ ls -l db4.*
-rw-r--r-- 1 sar 28 Oct 19 21:33 db4.dat
-rw-r--r-- 1 sar 72 Oct 19 21:33 db4.idx
$ cat db4.idx
0 53 35 0
0 10Alpha:0:6
0 10beta:6:14
17 11gamma:20:8
$ cat db4.dat
data1
Data for beta
record3
为了使这个例子紧凑,将每个指针字段的大小设置为4个ASCII字符,将散列链的数量设置为3条。由于每一个指针中记录的是一个文件偏移量,所以4个ASCII字符限制了一个索引文件或数据文件的大小最多只能为10 000字节。当在20.9节做性能测试时,将指针字段的大小设为6个字符(这样文件大小可以达到1 000 000字节),将散列链数量设为100。
图20-3 建立一个数据库并写入3条记录
索引文件的第一行为:
0 53 35 0
分别为空闲链表指针(0表示空闲链表为空)和3个散列链的指针:53、35和0。下一行:
0 10Alpha:0:6
显示了一条索引记录的结构。第一个 4 字符字段(0)为链表指针,表示这一条记录是此散列链的最后一条。下一个4字符字段(10)为idx len(索引记录长度),表示此索引记录剩余部分的长度。用两个read操作来读取一条索引记录:第一个read读取这两个固定长度的字段(链表指针和索引记录长度),然后再根据索引记录长度来读取后面的不定长部分。剩下的3个字段为:键、数据记录的偏移量和数据记录的长度。这 3 个字段用分隔符隔开,此处使用的分隔符是冒号。由于这 3个字段都是不定长的,所以需要一个专门的分隔符,而且这个分隔符不能出现在键中。最后用一个\n(换行符)结束这一条索引记录。由于在索引记录长度字段中已经有了记录的长度,所以这个换行符并不是必需的,加上换行符是为了把各条索引记录分开,这样就可以用标准的UNIX系统工具(如cat和more)来查看索引文件。键字段是将记录写入数据库时指定的值。数据记录在数据文件中的偏移量为0,长度为6。从数据文件中可看到数据记录确实从0开始,长度为6个字节。(与索引文件一样,这里自动在每条数据记录的后面追加一个换行符,以便于使用UNIX系统工具。在调用db_fetch时,此换行符不作为数据返回。)
如果在这个例子中跟踪 3 条散列链,可以看到第一条散列链上第一条记录的偏移量是 53 (gamma)。这条链上下一条记录的偏移量为 17(alpha),并且是这条链上的最后一条记录。第二条散列链上的第一条记录的偏移量是35(beta),且是此链上最后一条记录。第三条散列链为空。
请注意,索引文件中键的顺序和数据文件中对应数据记录的顺序与图 20-3 程序中调用 db_store的顺序一样。由于在调用db_open时使用了O_TRUNC标志,索引文件和数据文件都被截断了,整个数据库相当于重新初始化。在这种情况下,db_store将新的索引记录和数据记录追加到对应的文件末尾。后面将看到,db_store还可以重复使用这两个文件中已删除记录原来对应的空间。
使用固定大小的散列表作为索引是一个妥协。当每个散列链都不太长时,这个方法能保证快速地访问。我们的目的是能够快速地查找任一键,同时又不使用太复杂的数据结构(如B树或动态散列表)。动态散列表的优点是能保证仅用两次磁盘存取就能找到数据记录(详见Litwin[1980]或Fagin等[1979])。B树能够用(已排序的)键的顺序来遍历数据库(采用散列表的db_nextrec函数就做不到这一点)。
当有多个进程访问同一数据库时,有两种方法可实现库函数。
(1)集中式。由一个进程作为数据库管理者,所有的数据库访问工作由此进程完成。其他进程通过IPC机制与此中心进程进行联系。
(2)非集中式。每个库函数使用要求的并发控制(加锁),然后发起自己的I/O函数调用。
使用这两种技术的数据库系统都有。如果有适当的加锁例程,因为避免了使用 IPC,那么非集中式方法一般要快一些。图20-4描绘了集中式方法的操作。
图中特意表示出IPC像绝大多数UNIX系统的消息传递一样需要经过操作系统内核(15.9节中说明的共享存储不需要这种经过内核的复制)。在集中方式下,中心控制进程将记录读出,然后通过IPC机制将数据传递给请求进程。这是这种设计的不足之处。注意,集中式数据库管理进程是唯一对数据库文件进行I/O操作的进程。
集中式的优点是能够根据需要来对操作模式进行调整。例如,可以通过中心进程给不同的进程赋予不同的优先级,这会影响到中心进程对I/O操作的调度。而用非集中式方法则很难做到这一点。在这种情况下,只能依赖于操作系统内核的磁盘I/O调度策略和加锁策略(例如,当3个进程同时等待一个即将可用的锁时,我们无法确定哪个进程将得到这个锁)。
集中式方法的另一个优点是,恢复要比非集中式方法容易。在集中式方法中,所有状态信息都集中存放在一处,所以如若杀死了数据库进程,只需在该处查看以识别出需要解决的未完成事务,然后将数据库恢复到一致状态。
图20-4 集中式数据库访问
图20-5描绘了非集中式方法,本章的实现就是采用这种方法。
图20-5 非集中式数据库访问
调用数据库库函数执行I/O的用户进程是合作进程,它们使用字节范围记录锁机制来实现并发控制。
由于很多系统的实现都采用两个文件(一个索引文件和一个数据文件)的方法,所以在此也使用这种方法,这要求能够控制对两个文件的加锁。有很多方法可用来对两个文件进行加锁。
1.粗粒度锁
最简单的加锁方法是将这两个文件中的一个作为整个数据库的锁,并要求调用者在对数据库进行操作前必须获得这个锁。这种加锁方式称为粗粒度锁(coarse-grained locking)。例如,可以认为一个进程对索引文件的0字节加了读锁后,才能读整个数据库;一个进程对索引文件的0字节加了写锁后,就能写整个数据库。可以使用UNIX系统的字节范围锁机制来控制每次可以有多个读进程,而只能有一个写进程(见图14-3)。db_fetch和db_nextrec函数要求具有读锁,而db_delete、db_store和db_open则要求具有写锁。(db_open要求写锁的原因是如果要创建新文件的话,要在索引文件前端建立空闲区链表以及散列链表。)
粗粒度锁的问题是它限制了并发。用粗粒度锁时,当一个进程向一条散列链中添加一条记录时,其他进程无法访问另一条散列链上的记录。
2.细粒度锁
细粒度锁(fine-grained locking)的方法改进了粗粒度锁,提供了更高的并发性。一个读进程或写进程在操作一条记录前必须先获得此记录所在散列链的读锁或写锁。一条散列链允许同时有多个读进程,但只能有一个写进程。其次,一个写进程在访问空闲区链表(如 db_delete 或db_store)前,必须获得空闲区链表的写锁。最后,当db_store向索引文件或数据文件末尾追加一条新记录时,必须获得对应文件相应区域的写锁。
期望细粒度锁能比粗粒度锁能提供更高的并发性。20.9 节将给出一些实际的比较测试结果。20.8 节给出了细粒度锁实现的源代码,并讨论锁的实现细节(粗粒度锁是这个细粒度锁实现的简化)。
在源代码中,直接调用了read、readv、write和writev。没有使用标准I/O函数库。虽然使用标准I/O函数库也可以使用字节范围锁,但是需要非常复杂的缓冲管理。例如,标准I/O缓冲区的数据在5分钟之前被另一个进程修改了,那么我们就不希望fgets返回的数据是10分钟之前读入标准I/O缓冲区的数据。
以上对并发的讨论依据的是对数据库函数库的简单需求。商业系统一般有更多的需要。关于并发更多的细节可以参见Data[2004]的第16章。
数据库的函数库由两个文件构成,一个公用的C头文件以及一个C源文件。我们可以用下列命令构造一个静态函数库。
gcc -I../include -Wall -c db.c
ar rsv libapue_db.a db.o
因为我们在数据库函数库中使用了一些我们自己的公共函数,所以希望与libapue_db.a相连接的应用程序也需要与libapue.a相连接。
另一方面,如果想构建数据库函数库的动态共享库版本,可使用下列命令:
gcc -I../include -Wall -fPIC -c db.c
gcc -shared -Wl,-soname,libapue_db.so.1 -o libapue_db.so.1 \
-L../lib -lapue -lc db.o
构建成的共享库 libapue_db.so.1 需放置在动态连接程序/载入程序(dynamic linker/loader)能够找到的一个公用目录中。还可以将共享库放置在一个私有目录中,修改LD_LIBRARY_PATH 环境变量,使动态连接程序/载入程序的搜索路径包含该私有目录。
在不同平台间,构建共享库的步骤会有所不同。这里说明的步骤是在带GNU C编译器的Linux系统中进行的。
本节解释我们编写的数据库函数库源代码,先从头文件apue_db.h开始。函数库源代码以及调用此函数库的所有应用程序都包含这一头文件。
从此处开始,实例程序的编排方式在很多方面与前面的实例程序编排有所不同。首先,因为源代码较长,为此加了行号,这使得通过行号联系相应的源代码进行讨论更加方便。其次,对源代码的说明紧随相关源代码之后。
这种风格受到John Lions解释UNIX V6操作系统源代码的书[Lions 1977, 1996]的影响,这使得解释说明大量源代码更为简易。
注意,此处对空白行不编号。虽然某些工具(如 pr(1))的正常操作与这些空白行是有关的,但是我们对它们并无任何兴趣。
1 #ifndef _APUE_DB_H
2 #define _APUE_DB_H
3 typedef void * DBHANDLE;
4 DBHANDLE db_open(const char *, int, ...);
5 void db_close(DBHANDLE);
6 char *db_fetch(DBHANDLE, const char *);
7 int db_store(DBHANDLE, const char *, const char *, int);
8 int db_delete(DBHANDLE, const char *);
9 void db_rewind(DBHANDLE);
10 char *db_nextrec(DBHANDLE, char *);
11 /*
12 * Flags for db_store().13 */
14 #define DB_INSERT 1 /* insert new record only */
15 #define DB_REPLACE 2 /* replace existing record */
16 #define DB_STORE 3 /* replace or insert */
17 /*
18 * Implementation limits.
19 */
20 #define IDXLEN_MIN 6 /* key, sep, start, sep, length, \n */
21 #define IDXLEN_MAX 1024 /* arbitrary */
22 #define DATLEN_MIN 2 /* data byte, newline */
23 #define DATLEN_MAX 1024 /* arbitrary */
24 #endif /* _APUE_DB_H */
[1~3] 使用符号_APUE_DB_H以保证只包括该头文件一次。DBHANDLE类型表示对数据库的一个有效引用,用于隔离应用程序和数据库的实现细节。将此技术与标准I/O库向应用程序提供FILE结构相比较,两者相似。
[4~10] 接着,声明了数据库函数库公有函数的原型。因为使用函数库的应用程序包括了此头文件,所以这里不再声明函数库私有函数的原型。
[11~24] 定义了可以传送给 db_store 函数的合法标志。其后是实现的基本限制。如果希望支持更大的数据库,可以更改这些限制。
最小索引记录长度由IDXLEN_MIN指定。这表示1字节键、1字节分隔符、1字节起始偏移量,另一个1字节分隔符、1字节长度和终止换行符。(回忆图20-2中索引记录的格式。)一条索引记录通常长于IDXLEN_MIN字节,这只是最小长度。
下一个文件是db.c,它是库函数的C源文件。为简化起见,将所有函数都放在一个文件中。这样处理的优点是只要将私有函数声明为static,就可对外将它隐蔽起来。
1 #include "apue.h"
2 #include "apue_db.h"
3 #include <fcntl.h> /* open & db_open flags */
4 #include <stdarg.h>
5 #include <errno.h>
6 #include <sys/uio.h> /* struct iovec */
7 /*
8 * Internal index file constants.
9 * These are used to construct records in the
10 * index file and data file.
11 */
12 #define IDXLEN_SZ 4 /* index record length (ASCII chars) */
13 #define SEP ':' /* separator char in index record */
14 #define SPACE ' ' /* space character */
15 #define NEWLINE '\n' /* newline character */
16 /*
17 * The following definitions are for hash chains and free
18 * list chain in the index file.
19 */
20 #define PTR_SZ 7 /* size of ptr field in hash chain */
21 #define PTR_MAX 999999 /* max file offset = 10**PTR_SZ - 1 */
22 #define NHASH_DEF 137 /* default hash table size */
23 #define FREE_OFF 0 /* free list offset in index file */
24 #define HASH_OFF PTR_SZ /* hash table offset in index file */
25 typedef unsigned long DBHASH; /* hash values */
26 typedef unsigned long COUNT; /* unsigned counter */
[1~6] 使用了一些私有函数库中的函数,所以程序中包括了 apue.h。当然,apue.h 也包括若干标准头文件,包括<stdio.h>和<unistd.h>。因为 db_open 函数使用由<stdarg.h>定义的可变参数函数,所以程序中也包括了<stdarg.h>。
[7~26] 索引记录的长度说明为 IDXLEN_SZ。我们用某些字符(如冒号、换行符)作为数据库中的分隔符。当删除一记录时,在其中全部填入空格符。
其中一些定义为常量的值也可定义为变量,只是会使实现复杂一些。例如,设定散列表的大小为 137 记录项,也许更好的方法是让 db_open 的调用者根据预期的数据库大小通过参数来设定这个值,然后将该值存在索引文件的最前面。
27 /*
28 *Library's private representation of the database.
29 */
30 typedef struct {
31 int idxfd; /* fd for index file */
32 int datfd; /* fd for data file */
33 char *idxbuf; /* malloc'ed buffer for index record */
34 char *datbuf; /* malloc'ed buffer for data record*/
35 char *name; /* name db was opened under */
36 off_t idxoff; /* offset in index file of index record */
37 /* key is at (idxoff + PTR_SZ + IDXLEN_SZ) */
38 size_t idxlen; /* length of index record */
39 /* excludes IDXLEN_SZ bytes at front of record */
40 /* includes newline at end of index record */
41 off_t datoff; /* offset in data file of data record */
42 size_t datlen; /* length of data record */
43 /* includes newline at end */
44 off_t ptrval; /* contents of chain ptr in index record */
45 off_t ptroff; /* chain ptr offset pointing to this idx record */
46 off_t chainoff; /* offset of hash chain for this index record */
47 off_t hashoff; /* offset in index file of hash table */
48 DBHASH nhash; /* current hash table size */
49 COUNT cnt_delok; /* delete OK */
50 COUNT cnt_delerr; /* delete error */
51 COUNT cnt_fetchok; /* fetch OK */
52 COUNT cnt_fetcherr; /* fetch error */
53 COUNT cnt_nextrec; /* nextrec */
54 COUNT cnt_stor1; /* store: DB_INSERT, no empty, appended */
55 COUNT cnt_stor2; /* store: DB_INSERT, found empty, reused */
56 COUNT cnt_stor3; /* store: DB_REPLACE, diff len, appended */
57 COUNT cnt_stor4; /* store: DB_REPLACE, same len, overwrote */
58 COUNT cnt_storerr; /* store error */
59 } DB;
[27~48] 在 DB 结构中记录一个打开数据库的所有信息。db_open 函数返回 DB 结构的指针DBHANDLE值。这个指针被用于其他所有函数,而该结构本身则不面向调用者。
因为在数据库中以 ASCII 形式存放指针和长度,所以将这些转换为数字值,并存放在DB结构中。也存放散列表长度,虽然一般而言,这是定长的,但也有可能为加强该函数库,允许调用者在创建数据库时指定该长度(见习题20.7)。
[49~59] DB结构的最后10个字段对成功和不成功的操作进行计数。如果想要分析数据库的性能,则可编写一个函数返回这些统计值。但目前我们仅保持这些计数器,并未编写此种函数。
60 /*
61 *Internal functions.
62 */
63 static DB *_db_alloc(int);
64 static void _db_dodelete(DB *);
65 static int _db_find_and_lock(DB *, const char *, int);
66 static int _db_findfree(DB *, int, int);
67 static void _db_free(DB *);
68 static DBHASH _db_hash(DB *, const char *);
69 static char *_db_readdat(DB *);
70 static off_t _db_readidx(DB *, off_t);
71 static off_t _db_readptr(DB *, off_t);
72 static void _db_writedat(DB *, const char *, off_t, int);
73 static void _db_writeidx(DB *, const char *, off_t, int, off_t);
74 static void _db_writeptr(DB *, off_t, off_t);
75 /*
76 *Open or create a database. Same arguments as open(2).
77 */
78 DBHANDLE
79 db_open(const char *pathname, int oflag, ...)
80 {
81 DB *db;
82 int len, mode;
83 size_t i;
84 char asciiptr[PTR_SZ + 1],
85 hash[(NHASH_DEF + 1) * PTR_SZ + 2];
86 /* +2 for newline and null */
87 struct stat statbuff;
88 /*
89 * Allocate a DB structure, and the buffers it needs.
90 */
91 len = strlen(pathname);
92 if ((db = _db_alloc(len)) == NULL)
93 err_dump("db_open: _db_alloc error for DB");
[60~74] 选择用db_开头来命名用户可调用(公有)的所有函数,用_db_开头来命名内部(私有)函数。公有函数在函数库头文件apue_db.h中声明。内部函数声明为 static,所以只有同一文件中的其他函数才能调用它们(该文件包含函数库实现)。
[75~93] db_open函数的参数与open(2)相同。如果调用者想要创建数据库文件,那么用可选择的第三个参数指定文件权限。db_open函数打开索引文件和数据文件,在必要时初始化索引文件。该函数调用_db_alloc来为DB结构分配空间,并初始化此结构。
94 db->nhash = NHASH_DEF;/* hash table size */
95 db->hashoff = HASH_OFF; /* offset in index file of hash table */
96 strcpy(db->name, pathname);
97 strcat(db->name, ".idx");
98 if (oflag & O_CREAT) {
99 va_list ap;
100 va_start(ap, oflag);
101 mode = va_arg(ap, int);
102 va_end(ap);
103 /*
104 * Open index file and data file.
105 */
106 db->idxfd = open(db->name, oflag, mode);
107 strcpy(db->name + len, ".dat");
108 db->datfd = open(db->name, oflag, mode);
109 } else {
110 /*
111 * Open index file and data file.
112 */
113 db->idxfd = open(db->name, oflag);
114 strcpy(db->name + len, ".dat");
115 db->datfd = open(db->name, oflag);
116 }
117 if (db->idxfd < 0 || db->datfd < 0) {
118 _db_free(db);
119 return(NULL);
120 }
[94~97] 继续初始化 DB 结构。调用者传入的路径名指定数据库文件名的前缀。追加后缀.idx以构成数据库索引文件的名字。
[98~108] 如果调用者想要创建数据库文件,那么使用<stdarg.h>中的可变参数函数以找到可选的第三个参数。然后,使用 open 创建并打开索引文件和数据文件。注意,数据文件的文件名以索引文件同样的前缀开始,但后缀为.dat。
[109~116] 如果调用者没有指定O_CREAT标志,那么正在打开已有的数据库文件。此时,只用两个参数调用open。
[117~120] 如果在打开或创建任一数据库文件时出错,则调用_db_free清除DB结构,然后对调用者返回NULL。如果一个文件open成功而另一个失败,_db_free将关闭该打开文件描述符。我们很快就会见到这一操作。
121 if ((oflag & (O_CREAT | O_TRUNC)) == (O_CREAT | O_TRUNC)) {
122 /*
123 * If the database was created, we have to initialize
124 * it. Write lock the entire file so that we can stat
125 * it, check its size, and initialize it, atomically.
126 */
127 if (writew_lock(db->idxfd, 0, SEEK_SET, 0) < 0)
128 err_dump("db_open: writew_lock error");
129 if (fstat(db->idxfd, &statbuff) < 0)
130 err_sys("db_open: fstat error");
131 if (statbuff.st_size == 0) {
132 /*
133 * We have to build a list of (NHASH_DEF + 1) chain
134 * ptrs with a value of 0. The +1 is for the free
135 * list pointer that precedes the hash table.
136 */
137 sprintf(asciiptr, "%*d", PTR_SZ, 0);
[121~130] 如果正在建立数据库,则必须正确地加锁。考虑两个进程试图同时建立同一个数据库的情况。第一个进程运行到调用fstat,并且在fstat返回后被内核阻塞。
这时第二个进程调用db_open,发现索引文件的长度为0,然后初始化空闲链表和散列链表。第二个进程继续运行,向数据库中写入了一条记录。这时第二个进程被阻塞,第一个进程在调用fstat后立刻继续运行,它发现索引文件的长度为0(因为第一个进程调用fstat在前,然后第二个进程再初始化索引文件),所以第一个进程重新初始化空闲链表和散列链表,第二个进程写入的记录就被抹去了。
避免发生这种情况的方法是进行加锁,为此可以使用14.3节中的readw_lock、writew_lock和un_lock这3个宏。
[131~137] 如果索引文件的长度是 0,那么这是刚刚被创建的,所以需要初始化它所包含的空闲列表指针和散列链指针。注意,使用格式字符串%*d 将数据库指针从整型转换为ASCII字符串。(在_db_writeidx和_db_writeptr中还将使用这种格式字符串。)这一格式告诉sprintf取PTR_SZ参数,用它作为下一个参数的最小字段宽度,在此例中,它是 0(此处,因为正在创建一数据库,所以将指针初始化为0)。其作用是强迫创建的字符串至少包含PTR_SZ个字符(在左边用空格充填)。在_db_writeidx和_db_writeptr中,将传送一个非0指针值,但是首先将验证指针值不大于 PTR_MAX,以保证写入数据库的指针字符串恰好为PTR_SZ(7)个字符。
138 hash[0] = 0;
139 for (i = 0; i < NHASH_DEF + 1; i++)
140 strcat(hash, asciiptr);
141 strcat(hash, "\n");
142 i = strlen(hash);
143 if (write(db->idxfd, hash, i) != i)
144 err_dump("db_open: index file init write error");
145 }
146 if (un_lock(db->idxfd, 0, SEEK_SET, 0) < 0)
147 err_dump("db_open: un_lock error");
148 }
149 db_rewind(db);
150 return(db);
151 }
152 /*
153 * Allocate & initialize a DB structure and its buffers.
154 */
155 static DB *
156 _db_alloc(int namelen)
157 {
158 DB *db;
159 /*
160 * Use calloc, to initialize the structure to zero.
161 */
162 if ((db = calloc(1, sizeof(DB))) == NULL)
163 err_dump("_db_alloc: calloc error for DB");
164 db->idxfd = db->datfd = -1; /* descriptors */
165 /*
166 * Allocate room for the name.
167 * +5 for ".idx" or ".dat" plus null at end.
168 */
169 if ((db->name = malloc(namelen + 5)) == NULL)
170 err_dump("_db_alloc: malloc error for name");
[138~151] 继续初始化新创建的数据库。构造散列表,将它写到索引文件中。然后,解锁索引文件,重置数据库文件指针,返回DB结构指针作为句柄,以便调用者以后用于其他数据库函数。
[152~164] db_open调用函数_db_alloc为DB结构分配空间,包括一个索引缓冲区和一个数据缓冲区。用 calloc 分配存储区来存放 DB 结构,并将该存储区各存储单元全部初始化为0。这产生了一个副作用,也就是将数据库文件描述符也设置为0,为此需将它们重新设置为−1,表示它们至此还不是有效的。
[165~170] 分配空间以存放数据库索引文件和数据文件的名字。如 db_open 中所说明的那样,更改它们的名字后缀以便引用索引文件或数据文件。
171 /*
172 * Allocate an index buffer and a data buffer.
173 * +2 for newline and null at end.
174 */
175 if ((db->idxbuf = malloc(IDXLEN_MAX + 2)) == NULL)
176 err_dump("_db_alloc: malloc error for index buffer");
177 if ((db->datbuf = malloc(DATLEN_MAX + 2)) == NULL)
178 err_dump("_db_alloc: malloc error for data buffer");
179 return(db);
180 }
181 /*
182 * Relinquish access to the database.
183 */
184 void
185 db_close(DBHANDLE h)
186 {
187 _db_free((DB *)h); /* closes fds, free buffers & struct */
188 }
189 /*
190 * Free up a DB structure, and all the malloc'ed buffers it
191 * may point to. Also close the file descriptors if still open.
192 */
193 static void
194 _db_free(DB *db)
195 {
196 if (db->idxfd >= 0)
197 close(db->idxfd);
198 if (db->datfd >= 0)
199 close(db->datfd);
[171~180] 为索引文件和数据文件的缓冲区分配空间。索引缓冲区和数据缓冲区的大小在apue_db.h 中定义。可以通过让这些缓冲区按需要动态扩张来增强数据库函数库。其方法可以是记录这两个缓冲区的大小,然后在需要更大的缓冲区时调用realloc。最后,返回指向已分配到的DB结构的指针。
[181~188] db_close函数只是一个包装,它将数据库句柄强制类型转换为DB结构的指针,将它传送给_db_free函数,由该函数释放资源以及DB结构。
[189~199] db_open在打开索引文件和数据文件时如果发生错误,会调用_db_free函数释放资源。应用程序在结束对数据库的使用后,db_close也会调用_db_free。如果数据库索引文件的文件描述符有效,那么关闭该文件。对数据文件描述符也进行同样处理。(回忆在_db_alloc中分配一个新的DB结构时,将每个文件描述符都初始化为−1。如果不能打开两个数据库文件中的一个,相应文件描述符仍为−1,也就是无需关闭它。)
200 if (db->idxbuf != NULL)
201 free(db->idxbuf);
202 if (db->datbuf != NULL)
203 free(db->datbuf);
204 if (db->name != NULL)
205 free(db->name);
206 free(db);
207 }
208 /*
209 * Fetch a record. Return a pointer to the null-terminated data.
210 */
211 char *
212 db_fetch(DBHANDLE h, const char *key)
213 {
214 DB *db = h;
215 char *ptr;
216 if (_db_find_and_lock(db, key, 0) < 0) {
217 ptr = NULL; /* error, record not found */
218 db->cnt_fetcherr++;
219 } else {
220 ptr = _db_readdat(db); /* return pointer to data */
221 db->cnt_fetchok++;
222 }
223 /*
224 * Unlock the hash chain that _db_find_and_lock locked.
225 */
226 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
227 err_dump("db_fetch: un_lock error");
228 return(ptr);
229 }
[200~207] 接着,释放动态分配的缓冲区。可以安全地将一个空指针传递给 free 函数,这样也就无需事先检查每个缓冲区指针的值,但是我们认为只释放已分配的对象是一种较好的编程风格。(并非所有释放程序都像 free 那样容忍差错。)最后,释放DB结构占用的存储区。
[208~218] 函数db_fetch根据给定的键来读取一条记录。它调用_db_find_and_lock在数据库中查找记录。若不能找到该记录,则将返回值(ptr)设置为NULL,将不成功的记录搜索计数器值加 1。因为从_db_find_and_lock 返回时,数据库索引文件是加锁的,所以先要解锁,然后再返回。
[219~229] 如果找到了记录,调用_db_readdat读相应的数据记录,并将成功记录搜索计数器值加1。在返回前,调用un_lock对索引文件解锁。然后,返回所找到记录的指针(如果没有找到所需记录,则返回NULL)。
230 /*
231 * Find the specified record. Called by db_delete, db_fetch,
232 * and db_store. Returns with the hash chain locked.233 */
234 static int
235 _db_find_and_lock(DB *db, const char *key, int writelock)236 {
237 off_t offset, nextoffset;
238 /*
239 * Calculate the hash value for this key, then calculate the
240 * byte offset of corresponding chain ptr in hash table.
241 * This is where our search starts. First we calculate the
242 * offset in the hash table for this key.
243 */
244 db->chainoff = (_db_hash(db, key) * PTR_SZ) + db->hashoff;
245 db->ptroff = db->chainoff;
246 /*
247 * We lock the hash chain here. The caller must unlock it
248 * when done. Note we lock and unlock only the first byte.
249 */
250 if (writelock) {
251 if (writew_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
252 err_dump("_db_find_and_lock: writew_lock error");
253 } else {
254 if (readw_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
255 err_dump("_db_find_and_lock: readw_lock error");
256 }
257 /*
258 * Get the offset in the index file of first record
259 * on the hash chain (can be 0).
260 */
261 offset = _db_readptr(db, db->ptroff);
[230~237] _db_find_and_lock 函数在函数库内部用于按给定的键查找记录。在搜索记录时,如果想在索引文件上加一把写锁,则将writelock参数设置为非0值。如果将writelock参数设置为0,则在搜索记录时,在索引文件上加读锁。
[238~256] 在_db_find_and_lock 中准备遍历散列链。将键转换为散列值,用其计算在文件中相应散列链的起始地址(chainoff)。在遍历散列链前,等待获得锁。注意,只锁该散列链开始处的第 1 个字节。这种方式允许多个进程同时搜索不同的散列链,因此增加了并发性。
[257~261] 调用_db_readptr读散列链中的第一个指针。如果该函数返回0,则该散列链为空。
262 while (offset != 0) {
263 nextoffset = _db_readidx(db, offset);
264 if (strcmp(db->idxbuf, key) == 0)
265 break; /* found a match */
266 db->ptroff = offset; /* offset of this (unequal) record */
267 offset = nextoffset; /* next one to compare */
268 }
269 /*
270 * offset == 0 on error (record not found).
271 */
272 return(offset == 0 ? -1 : 0);
273 }
274 /*
275 * Calculate the hash value for a key.
276 */
277 static DBHASH
278 _db_hash(DB *db, const char *key)
279 {
280 DBHASH hval = 0;
281 char c;
282 int i;
283 for (i = 1; (c = *key++) != 0; i++)
284 hval += c * i; /* ascii char times its 1-based index */
285 return(hval % db->nhash);
286 }
[262~268] while循环遍历散列链中的每一条索引记录,并比较键。调用函数_db_readidx读取每条索引记录。它将当前记录的键填入 DB 结构中的 idxbuf 字段。如果_db_readidx返回0,则已到达散列链的最后一记录项。
[269~273] 如果在循环后,offset 为 0,说明已达到散列链末端而且没有找到匹配键,于是返回−1。否则,找到了匹配记录(用break语句退出了循环),所以返回0表示成功。此时,ptroff字段包含前一索引记录的地址,datoff包含数据记录的地址, datlen是数据记录的长度。当沿着散列链进行遍历时,必须始终保存当前索引记录的前一条索引记录,其中有一个指针指向当前索引记录。这样做在删除一条记录时很有用,因为必须修改当前索引记录的前一条记录的链指针以删除当前记录。
[274~286] _db_hash根据给定的键计算散列值。它将键中的每一个 ASCII字符乘以这个字符在字符串中以 1 开始的索引号,将这些结果加起来,除以散列表记录项数,将余数作为这个键的散列值。回忆散列表记录项数是 137,它是一个素数,按Knuth[1998],素数散列通常能提供良好的分布特性。
287 /*
288 * Read a chain ptr field from anywhere in the index file:
289 * the free list pointer, a hash table chain ptr, or an
290 * index record chain ptr.
291 */
292 static off_t
293 _db_readptr(DB *db, off_t offset)
294 {
295 char asciiptr[PTR_SZ + 1];
296 if (lseek(db->idxfd, offset, SEEK_SET) == -1)
297 err_dump("_db_readptr: lseek error to ptr field");
298 if (read(db->idxfd, asciiptr, PTR_SZ) != PTR_SZ)
299 err_dump("_db_readptr: read error of ptr field");
300 asciiptr[PTR_SZ] = 0; /* null terminate */
301 return(atol(asciiptr));
302 }
303 /*
304 * Read the next index record. We start at the specified offset
305 * in the index file. We read the index record into db->idxbuf
306 * and replace the separators with null bytes. If all is OK we
307 * set db->datoff and db->datlen to the offset and length of the
308 * corresponding data record in the data file.
309 */
310 static off_t
311 _db_readidx(DB *db, off_t offset)
312 {
313 ssize_t i;
314 char *ptr1, *ptr2;
315 char asciiptr[PTR_SZ + 1], asciilen[IDXLEN_SZ + 1];
316 struct iovec iov[2];
[287~302] _db_readptr函数读取以下3种不同链表指针中的任意一种:(a)索引文件最开始处指向空闲链表中第一个索引记录的指针,(b)散列表中指向散列链的第一条索引记录的指针,(c)存放在每条索引记录开始处、指向下一条记录的指针(这里的索引记录既可以处于一条散列链表中,也可以处于空闲链表中)。返回前,将指针从ASCII形式转换为长整型。此函数不进行任何加锁操作,所以其调用者应事先做好必要的加锁。
[303~316] _db_readidx函数用于从索引文件的指定偏移量处读取索引记录。如果成功,该函数将返回链表中下一条记录的偏移量。该函数还填充 DB 结构的许多字段:idxoff包含索引文件中当前记录的偏移量,ptrval包含在散列链表中下一个索引项的偏移量,idxlen包含当前索引记录的长度,idxbuf包含实际索引记录, datoff包含数据文件中该记录的偏移量,datlen包含该数据记录的长度。
317 /*
318 * Position index file and record the offset. db_nextrec
319 * calls us with offset==0, meaning read from current offset.
320 * We still need to call lseek to record the current offset.
321 */
322 if ((db->idxoff = lseek(db->idxfd, offset,
323 offset == 0 ? SEEK_CUR : SEEK_SET)) == -1)
324 err_dump("_db_readidx: lseek error");
325 /*
326 * Read the ascii chain ptr and the ascii length at
327 * the front of the index record. This tells us the
328 * remaining size of the index record.
329 */
330 iov[0].iov_base = asciiptr;
331 iov[0].iov_len = PTR_SZ;
332 iov[1].iov_base = asciilen;
333 iov[1].iov_len = IDXLEN_SZ;
334 if ((i = readv(db->idxfd, &iov[0], 2)) != PTR_SZ + IDXLEN_SZ) {
335 if (i == 0 && offset == 0)
336 return(-1); /* EOF for db_nextrec */
337 err_dump("_db_readidx: readv error of index record");
338 }
339 /*
340 * This is our return value; always >= 0.
341 */
342 asciiptr[PTR_SZ] = 0; /* null terminate */
343 db->ptrval = toll(asciiptr); /* offset of next key in chain */
344 asciilen[IDXLEN_SZ] = 0; /* null terminate */
345 if ((db->idxlen = atoi(asciilen)) < IDXLEN_MIN ||
346 db->idxlen > IDXLEN_MAX)
347 err_dump("_db_readidx: invalid length");
[317~324] 按调用者提供的参数查找索引文件偏移量。在DB结构中记录该偏移量,为此即使调用者想要在当前文件偏移量处读记录(设置offset为0),仍需要调用lseek以确定当前偏移量。因为在索引文件中,索引记录决不会存放在偏移量为 0 处,所以可以放心地使用0表示“从当前偏移量处读”。
[325~338] 调用readv读在索引记录开始处的两个定长字段:指向下一索引记录的链指针和该索引记录余下部分的长度(余下部分是变长的)。
[339~347] 将下一记录的偏移量转换为整型,并存放到ptrval字段中(这将被用作此函数的返回值)。然后将索引记录的长度转换为整型,并存放到idxlen字段中。
348 /*
349 * Now read the actual index record. We read it into the key
350 * buffer that we malloced when we opened the database.
351 */
352 if ((i = read(db->idxfd, db->idxbuf, db->idxlen)) != db->idxlen)
353 err_dump("_db_readidx: read error of index record");
354 if (db->idxbuf[db->idxlen-1] != NEWLINE) /* sanity check */
355 err_dump("_db_readidx: missing newline");
356 db->idxbuf[db->idxlen-1] = 0; /* replace newline with null */
357 /*
358 * Find the separators in the index record.
359 */
360 if ((ptr1 = strchr(db->idxbuf, SEP)) == NULL)
361 err_dump("_db_readidx: missing first separator");
362 *ptr1++ = 0; /* replace SEP with null */
363 if ((ptr2 = strchr(ptr1, SEP)) == NULL)
364 err_dump("_db_readidx: missing second separator");
365 *ptr2++ = 0; /* replace SEP with null */
366 if (strchr(ptr2, SEP) != NULL)
367 err_dump("_db_readidx: too many separators");
368 /*
369 * Get the starting offset and length of the data record.
370 */
371 if ((db->datoff = atol(ptr1)) < 0)
372 err_dump("_db_readidx: starting offset < 0");
373 if ((db->datlen = atol(ptr2)) <= 0 || db->datlen > DATLEN_MAX)
374 err_dump("_db_readidx: invalid length");
375 return(db->ptrval); /* return offset of next key in chain */
376 }
[348~356] 将索引记录的变长部分读入DB结构中的idxbuf字段。该记录应以换行符结尾。
用null字符代替换行符。如果索引文件已遭破坏,那么调用err_dump函数终止core文件。
[357~367] 将索引记录划分成 3 个字段:键、对应数据记录的偏移量和数据记录的长度。
strchr 函数在给定字符串中找到第一个指定字符。这里,我们要寻找的是记录中分隔字段的字符(SEP,此处定义为冒号)。
[368~376] 将数据记录偏移量和数据记录长度转换为整型,并将它们存放在DB结构中。然后,返回在散列链中下一条记录的偏移量。注意,我们并不读数据记录,这由调用者自己完成。例如,在db_fetch中,在_db_find_and_lock按键找到索引记录前是不读取数据记录的。
377 /*
378 * Read the current data record into the data buffer.
379 * Return a pointer to the null-terminated data buffer.
380 */
381 static char *
382 _db_readdat(DB *db)
383 {
384 if (lseek(db->datfd, db->datoff, SEEK_SET) == -1)
385 err_dump("_db_readdat: lseek error");
386 if (read(db->datfd, db->datbuf, db->datlen) != db->datlen)
387 err_dump("_db_readdat: read error");
388 if (db->datbuf[db->datlen-1] != NEWLINE) /* sanity check */
389 err_dump("_db_readdat: missing newline");
390 db->datbuf[db->datlen-1] = 0; /* replace newline with null */
391 return(db->datbuf); /* return pointer to data record */
392 }
393 /*
394 * Delete the specified record.
395 */
396 int
397 db_delete(DBHANDLE h, const char *key)398 {
399 DB *db = h;
400 int rc = 0; /* assume record will be found */
401 if (_db_find_and_lock(db, key, 1) == 0) {
402 _db_dodelete(db);
403 db->cnt_delok++;
404 } else {
405 rc = -1; /* not found */
406 db->cnt_delerr++;
407 }
408 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
409 err_dump("db_delete: un_lock error");
410 return(rc);
411 }
[377~392] 在datoff和datlen已经被正确初始化后,_db_readdat函数将数据记录的内容读入DB结构中的datbuf字段指向的缓冲区。
[393~411] db_delete函数用于删除与给定键匹配的一条记录。使用_db_find_and_lock来判断在数据库中该记录是否存在。如果存在,则调用_db_dodelete函数执行删除该记录的操作。_db_find_and_lock 的第三个参数控制对散列链是加读锁还是写锁。此处,因为可能执行更改该链表的操作,所以要加一把写锁。_db_find_and_lock 返回时,这把锁仍旧存在,为此不管是否找到了所需的记录,都需要解除这把锁。
412 /*
413 * Delete the current record specified by the DB structure.
414 * This function is called by db_delete and db_store, after
415 * the record has been located by _db_find_and_lock.416 */
417 static void
418 _db_dodelete(DB *db)419 {
420 int i;
421 char *ptr;
422 off_t freeptr, saveptr;
423 /*
424 * Set data buffer and key to all blanks.
425 */
426 for (ptr = db->datbuf, i = 0; i < db->datlen - 1; i++)
427 *ptr++ = SPACE;
428 *ptr = 0; /* null terminate for _db_writedat */
429 ptr = db->idxbuf;
430 while (*ptr)
431 *ptr++ = SPACE;
432 /*
433 * We have to lock the free list.
434 */
435 if (writew_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
436 err_dump("_db_dodelete: writew_lock error");
437 /*
438 * Write the data record with all blanks.
439 */
440 _db_writedat(db, db->datbuf, db->datoff, SEEK_SET);
[412~431] _db_dodelete 函数执行从数据库中删除一条记录的所有操作。(该函数也可以由db_store调用。)此函数的大部分工作仅仅是更新空闲链表以及与键对应的散列链。当一条记录被删除后,将其键和数据记录设为空。本章后面将提到的函数db_nextrec要用到这一点。
[432~440] 调用 writew_lock 对空闲链表加写锁,这样能防止两个进程同时删除不同链表上的记录时产生相互影响,因为要将被删除的记录添加到空闲链表中,这将改变空闲链表指针,而一次只能有一个进程能这样做。
调用函数_db_writedat清空数据记录。这时_db_writedat并不对数据文件加写锁,这是因为 db_delete 对这条记录的散列链已经加了写锁,这保证不会再有其他进程能够读、写这条记录。
441 /*
442 * Read the free list pointer. Its value becomes the
443 * chain ptr field of the deleted index record. This means
444 * the deleted record becomes the head of the free list.
445 */
446 freeptr = _db_readptr(db, FREE_OFF);
447 /*
448 * Save the contents of index record chain ptr,
449 * before it's rewritten by _db_writeidx.
450 */
451 saveptr = db->ptrval;
452 /*
453 * Rewrite the index record. This also rewrites the length
454 * of the index record, the data offset, and the data length,
455 * none of which has changed, but that's OK.
456 */
457 _db_writeidx(db, db->idxbuf, db->idxoff, SEEK_SET, freeptr);
458 /*
459 * Write the new free list pointer.
460 */
461 _db_writeptr(db, FREE_OFF, db->idxoff);
462 /*
463 * Rewrite the chain ptr that pointed to this record being
464 * deleted. Recall that _db_find_and_lock sets db->ptroff to
465 * point to this chain ptr. We set this chain ptr to the
466 * contents of the deleted record's chain ptr, saveptr.
467 */
468 _db_writeptr(db, db->ptroff, saveptr);
469 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
470 err_dump("_db_dodelete: un_lock error");
471 }
[441~461] 读空闲链表指针,接着修改索引记录。让这条记录的下一条记录指针指向空闲链表的第一条记录(如果空闲链表为空,则这个新的链表指针置为0)。清除键之后用正被删除索引记录的偏移量更新空闲链表指针,也就是使其指向当前删除的这条记录。这意味着空闲链表的处理基于后进先出(虽然是以首次适应算法来删除空闲链表项),也就是说被删除的记录都被添加到空闲链表头部。
没有为每个文件分别设置空闲链表。将一个删除的索引记录添加到空闲链表时,该索引记录仍指向已删除的数据记录。当然还有更好的处理方法,但复杂性会增加。[462~471] 修改散列链中前一条记录的指针,使其指向正删除记录之后的记录,这样就从散列链中移除了要删除的记录。最后对空闲链表解锁。770
472 /*
473 * Write a data record. Called by _db_dodelete (to write
474 * the record with blanks) and db_store.475 */
476 static void
477 _db_writedat(DB *db, const char *data, off_t offset, int whence)478 {
479 struct iovec iov[2];
480 static char newline = NEWLINE;
481 /*
482 * If we're appending, we have to lock before doing the lseek
483 * and write to make the two an atomic operation. If we're
484 * overwriting an existing record, we don't have to lock.
485 */
486 if (whence == SEEK_END) /* we're appending, lock entire file */
487 if (writew_lock(db->datfd, 0, SEEK_SET, 0) < 0)
488 err_dump("_db_writedat: writew_lock error");
489 if ((db->datoff = lseek(db->datfd, offset, whence)) == -1)
490 err_dump("_db_writedat: lseek error");
491 db->datlen = strlen(data) + 1; /* datlen includes newline */
492 iov[0].iov_base = (char *) data;
493 iov[0].iov_len = db->datlen - 1;
494 iov[1].iov_base = &newline;
495 iov[1].iov_len = 1;
496 if (writev(db->datfd, &iov[0], 2) != db->datlen)
497 err_dump("_db_writedat: writev error of data record");
498 if (whence == SEEK_END)
499 if (un_lock(db->datfd, 0, SEEK_SET, 0) < 0)
500 err_dump("_db_writedat: un_lock error");
501 }
[472~491] 调用函数_db_writedat 写一个数据记录。当删除一记录时,调用函数_db_writedat 清空数据记录;这时_db_writedat 并不对数据文件加写锁,因为db_delete 对这条记录的散列链已经加了写锁,这保证不会再有其他进程能够读、写这条记录。在本节稍后处说明db_store函数时,会遇到_db_writedat函数追加写数据文件的情况,此时就必需对该文件加锁。
定位到要写数据记录的位置。要写的字节数是记录长度加1个字节,这1个字节是表示记录终止的换行符。
[492~501] 设置iovec数组,调用writev写数据记录和换行符。不能想当然地认为调用者缓冲区的尾端有空间可以追加换行符,所以应该将换行符写入另一个缓冲区,然后再从该缓冲区写至数据记录。如果正在对文件追加一条记录,那么就释放早先获得的锁。
502 /*
503 * Write an index record. _db_writedat is called before
504 * this function to set the datoff and datlen fields in the
505 * DB structure, which we need to write the index record.
506 */
507 static void
508 _db_writeidx(DB *db, const char *key,
509 off_t offset, int whence, off_t ptrval)
511 struct iovec iov[2];
512 char asciiptrlen[PTR_SZ + IDXLEN_SZ + 1];
513 int len;
510 {
514 if ((db->ptrval = ptrval) < 0 || ptrval > PTR_MAX)
515 err_quit("_db_writeidx: invalid ptr: %d", ptrval);
516 sprintf(db->idxbuf, "%s%c%lld%c%ld\n", key, SEP,
517 (long long)db->datoff, SEP, (long)db->datlen);
518 len = strlen(db->idxbuf);
519 if (len < IDXLEN_MIN || len > IDXLEN_MAX)
520 err_dump("_db_writeidx: invalid length");
521 sprintf(asciiptrlen, "%*lld%*d", PTR_SZ, (long long)ptrval,
522 IDXLEN_SZ, len);
523 /*
524 * If we’re appending, we have to lock before doing the lseek
525 * and write to make the two an atomic operation. If we’re
526 * overwriting an existing record, we don’t have to lock.
527 */
528 if (whence == SEEK_END) /* we’re appending */
529 if (writew_lock(db->idxfd, ((db->nhash+1)*PTR_SZ)+1,
530 SEEK_SET, 0) < 0)
531 err_dump("_db_writeidx: writew_lock error");
[502~522] 调用_db_writeidx函数写一条索引记录。在验证散列链中下一个指针有效后,创建索引记录,并将它的后半部分存放到idxbuf中。需要索引记录这一部分的长度以创建该记录的前半部分,而前半部分被存放到局部变量asciiptrlen中。
注意,使用强制类型转换使得sprintf语句的参数的长度与格式说明中相匹配,这样做是因为off_t和size_t数据类型的长度因平台不同而不同。32位系统也能提供64位文件偏移量,所以不能假定off_t数据类型的长度。
[523~531] 和_db_writedat 一样,只有在追加新索引记录时这一函数才需要加锁。
_db_dodelete调用此函数是为了重写一条已有的索引记录。在这种情况下,调用者已经在散列链上加了写锁,所以不再需要加另外的锁。
532 /*
533 * Position the index file and record the offset.
534 */
535 if ((db->idxoff = lseek(db->idxfd, offset, whence)) == -1)
536 err_dump("_db_writeidx: lseek error");
537 iov[0].iov_base = asciiptrlen;
538 iov[0].iov_len = PTR_SZ + IDXLEN_SZ;
539 iov[1].iov_base = db->idxbuf;
540 iov[1].iov_len = len;
541 if (writev(db->idxfd, &iov[0], 2) != PTR_SZ + IDXLEN_SZ + len)
542 err_dump("_db_writeidx: writev error of index record");
543 if (whence == SEEK_END)
544 if (un_lock(db->idxfd, ((db->nhash+1)*PTR_SZ)+1,
545 SEEK_SET, 0) < 0)
546 err_dump("_db_writeidx: un_lock error");
547 }
548 /*
549 * Write a chain ptr field somewhere in the index file:
550 * the free list, the hash table, or in an index record.
551 */
552 static void
553 _db_writeptr(DB *db, off_t offset, off_t ptrval)554 {
555 char asciiptr[PTR_SZ + 1];
556 if (ptrval < 0 || ptrval > PTR_MAX)
557 err_quit("_db_writeptr: invalid ptr: %d", ptrval);
558 sprintf(asciiptr, "%*lld", PTR_SZ, (long long)ptrval);
559 if (lseek(db->idxfd, offset, SEEK_SET) == -1)
560 err_dump("_db_writeptr: lseek error to ptr field");
561 if (write(db->idxfd, asciiptr, PTR_SZ) != PTR_SZ)
562 err_dump("_db_writeptr: write error of ptr field");
563 }
[532~547] 定位到开始写索引记录的位置,将该偏移量存入 DB 结构的 idxoff 字段。因为在两个独立的缓冲区中构建索引记录,所以调用writev将它存放到索引文件中。
如果是追加写该文件,则释放在定位操作前获得的锁。从并发运行进程追加新记录到数据库的角度思考问题,那么这把锁使定位操作和写操作成为原子操作。
[548~563] _db_writeptr被用于将一散列链指针写至索引文件中。验证该指针在索引文件的边界范围内,然后将它转换成ASCII字符串。按指定的偏移量在索引文件中定位,然后将该指针ASCII字符串写入索引文件。
564 /*
565 * Store a record in the database. Return 0 if OK, 1 if record
566 * exists and DB_INSERT specified, -1 on error.
567 */
568 int
569 db_store(DBHANDLE h, const char *key, const char *data, int flag)
570 {
571 DB *db = h;
572 int rc, keylen, datlen;
573 off_t ptrval;
574 if (flag != DB_INSERT && flag != DB_REPLACE &&
575 flag != DB_STORE) {
576 errno = EINVAL;
577 return(-1);
578 }
579 keylen = strlen(key);
580 datlen = strlen(data) + 1; /* +1 for newline at end */
581 if (datlen < DATLEN_MIN || datlen > DATLEN_MAX)
582 err_dump("db_store: invalid data length");
583 /*
584 * _db_find_and_lock calculates which hash table this new record
585 * goes into (db->chainoff), regardless of whether it already
586 * exists or not. The following calls to _db_writeptr change the
587 * hash table entry for this chain to point to the new record.
588 * The new record is added to the front of the hash chain.
589 */
590 if (_db_find_and_lock(db, key, 1) < 0) { /* record not found */
591 if (flag == DB_REPLACE) {
592 rc = -1;
593 db->cnt_storerr++;
594 errno = ENOENT; /* error, record does not exist */
595 goto doreturn;
596 }
[564~582] db_store函数的功能是将一条记录添加到数据库中。首先验证参数flag的值。然后,检查数据记录长度是否有效。如果无效,则删除core文件并终止。作为一个例子这样处理无可厚非,但如果构造正式应用的函数库,那么最好返回出错状态而非终止,这样可以给应用程序一个恢复的机会。
[583~596] 调用_db_find_and_lock以查看这个记录是否已经存在。如果记录并不存在且指定的标志为 DB_INSERT 或 DB_STORE,或者记录存在且指定的标志为 DB_REPLACE或 DB_STORE,那么这些都是允许的。替换一条已有的记录意味着键不变,而数据记录很可能不同。注意,因为 db_store 很可能会改变散列链,所以调用_db_find_and_lock的最后一个参数指明要对散列链加写锁。
597 /*
598 * _db_find_and_lock locked the hash chain for us; read
599 * the chain ptr to the first index record on hash chain.
600 */
601 ptrval = _db_readptr(db, db->chainoff);
602 if (_db_findfree(db, keylen, datlen) < 0) {
603 /*
604 * Can't find an empty record big enough. Append the
605 * new record to the ends of the index and data files.
606 */
607 _db_writedat(db, data, 0, SEEK_END);
608 _db_writeidx(db, key, 0, SEEK_END, ptrval);
609 /*
610 * db->idxoff was set by _db_writeidx. The new
611 * record goes to the front of the hash chain.
612 */
613 _db_writeptr(db, db->chainoff, db->idxoff);
614 db->cnt_stor1++;
615 } else {
616 /*
617 * Reuse an empty record. _db_findfree removed it from
618 * the free list and set both db->datoff and db->idxoff.
619 * Reused record goes to the front of the hash chain.
620 */
621 _db_writedat(db, data, db->datoff, SEEK_SET);
622 _db_writeidx(db, key, db->idxoff, SEEK_SET, ptrval);
623 _db_writeptr(db, db->chainoff, db->idxoff);
624 db->cnt_stor2++;
625 }
[597~601] 在调用_db_find_and_lock后,代码分成4种情况。前两种情况中,没有找到足够大的空闲记录,所以添加一条新纪录。读散列链上第一项的偏移量。
[602~614] 第1种情况:调用_db_findfree在空闲链表中搜索一条已删除的记录,它的键长度和数据长度与参数keylen和datlen相同。如果没有找到对应大小的空闲记录,这意味着要将这条新记录追加到索引文件和数据文件的末尾。调用_db_writedat写数据部分,调用_db_writeidx写索引部分,调用_db_writeptr将新记录添加到对应的散列链的头部。将执行此种情况的计数器(cnt_stor1)值加1,以便观察数据库的运行状况。
[615~625] 第2种情况:_db_findfree找到对应大小的空记录,然后将这条空记录从空闲链表中移除(稍后就会看到_db_findfree的实现),写入新的索引记录和数据记录,然后,如同第 1 种情况一样,将新记录添加到对应的散列链的头部。将执行此种情况的计数器(cnt_stor2)值加 1,以便观察数据库的运行状况。
626 } else { /* record found */
627 if (flag == DB_INSERT) {
628 rc = 1; /* error, record already in db */
629 db->cnt_storerr++;
630 goto doreturn;
631 }
632 /*
633 * We are replacing an existing record. We know the new
634 * key equals the existing key, but we need to check if
635 * the data records are the same size.
636 */
637 if (datlen != db->datlen) {
638 _db_dodelete(db); /* delete the existing record */
639 /*
640 * Reread the chain ptr in the hash table
641 * (it may change with the deletion).
642 */
643 ptrval = _db_readptr(db, db->chainoff);
644 /*
645 * Append new index and data records to end of files.
646 */
647 _db_writedat(db, data, 0, SEEK_END);
648 _db_writeidx(db, key, 0, SEEK_END, ptrval);
649 /*
650 * New record goes to the front of the hash chain.
651 */
652 _db_writeptr(db, db->chainoff, db->idxoff);
653 db->cnt_stor3++;
654 } else {
[626~631] 另两种情况是具有相同键的记录在数据库中已存在,如果不想替换该记录,则设置表示一条记录已经存在的返回码,将存储出错计数的计数器 cnt_storerr 值加1,然后跳转至函数末尾,在此处理公共返回逻辑。
[632~654] 第 3 种情况:要替换一条已有记录,而新数据记录的长度与已有记录的长度不一样。调用_db_dodelete删除已有记录,将该删除记录放在空闲链表头部。然后,调用_db_writedat 和_db_writeidx 将新记录追加到索引文件和数据文件的末尾(也可以用其他方法,如可以再找一找是否有数据大小正好的已删除的记录项)。最后调用_db_writeptr将新记录添加到对应的散列链的头部。DB结构中的cnt_stor3计数器记录发生此种情况的次数。
655 /*
656 * Same size data, just replace data record.
657 */
658 _db_writedat(db, data, db->datoff, SEEK_SET);
659 db->cnt_stor4++;
660 }
661 }
662 rc = 0; /* OK */
663 doreturn: /* unlock hash chain locked by _db_find_and_lock */
664 if (un_lock(db->idxfd, db->chainoff, SEEK_SET, 1) < 0)
665 err_dump("db_store: un_lock error");
666 return(rc);667 }668 /*
669 * Try to find a free index record and accompanying data record
670 * of the correct sizes. We're only called by db_store.671 */
672 static int
673 _db_findfree(DB *db, int keylen, int datlen)674 {
675 int rc;
676 off_t offset, nextoffset, saveoffset;
677 /*
678 * Lock the free list.
679 */
680 if (writew_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
681 err_dump("_db_findfree: writew_lock error");
682 /*
683 * Read the free list pointer.
684 */
685 saveoffset = FREE_OFF;
686 offset = _db_readptr(db, saveoffset);
[655~661] 第 4 种情况:替换一条已有记录,而新数据记录的长度与已有记录的长度恰好一样。这是最容易的情况,只需要重写数据记录即可,并将这种情况的计数器(cnt_stor4)值加1。
[662~667] 在正常情况下,设置表示成功的返回码,然后进入公共返回逻辑。对散列链解锁(这把锁是由调用_db_find_and_lock而加上的),然后返回调用者。
[668~686] dbfindfree函数试图找到一个指定大小的空闲索引记录和相关联的数据记录。需要对空闲链表加写锁以避免与其他使用空闲链表的进程互相影响。在对空闲链表加写锁后,得到空闲链表的头指针地址。
687 while (offset != 0) {
688 nextoffset = _db_readidx(db, offset);
689 if (strlen(db->idxbuf) == keylen && db->datlen == datlen)
690 break; /* found a match */
691 saveoffset = offset;
692 offset = nextoffset;
693 }
694 if (offset == 0) {
695 rc = -1; /* no match found */
696 } else {
697 /*
698 * Found a free record with matching sizes.
699 * The index record was read in by _db_readidx above,
700 * which sets db->ptrval. Also, saveoffset points to
701 * the chain ptr that pointed to this empty record on
702 * the free list. We set this chain ptr to db->ptrval,
703 * which removes the empty record from the free list.
704 */
705 _db_writeptr(db, saveoffset, db->ptrval);
706 rc = 0;
707 /*
708 * Notice also that _db_readidx set both db->idxoff
709 * and db->datoff. This is used by the caller, db_store,
710 * to write the new index record and data record.
711 */
712 }
713 /*
714 * Unlock the free list.
715 */
716 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
717 err_dump("_db_findfree: un_lock error");
718 return(rc);
719 }
[687~693] _db_findfree 中的 while 循环遍历空闲链表以搜寻一个能够匹配键长度和数据长度的索引记录项。在这个简单的实现中,只有当一个已删除记录的键长度及数据长度与要插入的新记录的键长度及数据长度一样时才重用已删除记录的空间。还有其他更好的算法,但复杂度会增加。
[694~712] 如果找不到所要求键长度和数据长度的可用记录,则设置表示失败的返回码。否则,将已找到记录的下一个链指针写至前一记录的链表指针。这样就从空闲链表中移除了该记录。[713~719] 一旦结束对空闲链表的操作,立即释放写锁。然后对调用者返回状态码。
720 /*
721 * Rewind the index file for db_nextrec.722 * Automatically called by db_open.723 * Must be called before first db_nextrec.724 */
725 void
726 db_rewind(DBHANDLE h)727 {
728 DB *db = h;
729 off_t offset;
730 offset = (db->nhash + 1) * PTR_SZ; /* +1 for free list ptr */
731 /*
732 * We're just setting the file offset for this process
733 * to the start of the index records; no need to lock.
734 * +1 below for newline at end of hash table.
735 */
736 if ((db->idxoff = lseek(db->idxfd, offset+1, SEEK_SET)) == -1)
737 err_dump("db_rewind: lseek error");738 }739 /*
740 * Return the next sequential record.
741 * We just step our way through the index file, ignoring deleted
742 * records. db_rewind must be called before this function is
743 * called the first time.
744 */
745 char *
746 db_nextrec(DBHANDLE h, char *key)747 {
748 DB *db = h;
749 char c;
750 char *ptr;
[720~738] db_rewind函数用于把数据库重置到“起始状态”,将索引文件的文件偏移量设置为指向第一条索引记录(紧跟在散列表之后)。(回忆图20-2中索引文件的结构。)
[739~750] db_nextrec 函数返回数据库的下一条记录。返回值是指向数据缓冲区的指针。如果调用者提供的key参数非空,将相应的键复制到该缓冲区中。调用者负责分配可以存放键的足够大的缓冲区。大小为IDXLEN_MAX字节的缓冲区足够存放任意键。记录按数据库文件中存放的顺序逐一返回。也就是说,记录并不按键值大小排序。另外,db_nextrec并不跟随散列链表,所以已删除的记录也会被读取,但是不向调用者返回这种已删除记录。
751 /*
752 * We read lock the free list so that we don't read
753 * a record in the middle of its being deleted.
754 */
755 if (readw_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
756 err_dump("db_nextrec: readw_lock error");
757 do {
758 /*
759 * Read next sequential index record.
760 */
761 if (_db_readidx(db, 0) < 0) {
762 ptr = NULL; /* end of index file, EOF */
763 goto doreturn;
764 }
765 /*
766 * Check if key is all blank (empty record).
767 */
768 ptr = db->idxbuf;
769 while ((c = *ptr++) != 0 && c == SPACE)
770 ; /* skip until null byte or nonblank */
771 } while (c == 0);/* loop until a nonblank key is found */
772 if (key != NULL)
773 strcpy(key, db->idxbuf); /* return key */
774 ptr = _db_readdat(db);/* return pointer to data buffer */
775 db->cnt_nextrec++;
776 doreturn:
777 if (un_lock(db->idxfd, FREE_OFF, SEEK_SET, 1) < 0)
778 err_dump("db_nextrec: un_lock error");
779 return(ptr);
780 }
[751~756] 对空闲链表加读锁,使得正在读该链表时,其他进程不能从中移除记录。
[757~771] 调用_db_readidx读下一个记录。传送给该函数的偏移量参数值为0,以此通知该函数从当前偏移量继续读索引记录。因为正在逐条顺序读索引文件,所以会读到已删除的记录。仅需返回有效记录,所以跳过键是全空格的记录(回忆_db_dodelete函数以设置全空格方式清除键)。
[772~780] 当找到一有效键时,如果调用者已提供缓冲区,则将该键复制到该缓冲区。然后读数据记录,并将返回值设置为指向包含数据记录的内部缓冲区的指针值。将统计计数器值加1,对空闲链表解锁,最后返回指向数据记录的指针。
通常在下列形式的循环中使用db_rewind和db_nextrec这两个函数:
db_rewind(db);
while ((ptr = db_nextrec(db, key)) != NULL) {
/* process record */
}
前面曾警告过,记录的返回没有一定的顺序,它们并不按键的顺序返回。
如果db_nextrec函数在循环中被调用时数据库正在被修改,则db_nextrec返回的记录只是变化中的数据库在某一时间点的快照(snapshot)。db_nextrec被调用时总是返回一条“正确”的记录,也就是说它不会返回一条已删除的记录。但有可能一条记录刚被db_nextrec返回后就被删除。类似地,如果db_nextrec刚跳过一条已删除的记录,这条记录的空间就被一条新记录重用,除非用db_rewind重新遍历一遍,否则在结果中看不到这条新的记录。如果通过db_nextrec获得一份数据库的准确的“冻结”的快照很重要,则在这段时间内应该不做插入和删除操作。
下面来看db_nextrec使用的加锁。因为并不使用任何散列链表,也不能判断每条记录属于哪条散列链。所以有可能当db_nextrec读取一条记录时,其索引记录正在被删除。为了防止这种情况,db_nextrec 对空闲链表加读锁,这样就可避免与_db_dodelete 和_db_findfree相互影响。
在结束对 db.c 源文件的说明之前,对向文件末尾追加索引记录或数据记录时的加锁再做一些说明。在第1种和第3种情况中,db_store调用_db_writeidx和_db_writedat时,第3个参数为0,第4个参数为SEEK_END。这里,第4个参数作为一个标志用来告诉这两个函数,新的记录将被追加到文件的末尾。_db_writeidx用到的技术是对索引文件加写锁,加锁的范围从散列链的末尾到文件的末尾。这不会影响其他数据库的读进程和写进程(这些进程将对散列链加锁),但如果其他进程此时调用 db_store 来追加数据则会被锁住。_db_writedat使用的方法是对整个数据文件加写锁。同样这也不会影响其他数据库的读进程和写进程(它们甚至不对数据文件加锁),但如果其他用户此时调用 db_store 来向数据文件追加数据则会被锁住(见习题20.3)。
为了测试这一数据库函数库,也为了获得一些与典型应用的数据访问模式有关的时间测量数据,编写了一个测试程序。该程序接受两个命令行参数:要创建的子进程的个数和每个子进程向数据库写的数据记录的条数(nrec)。然后(通过调用db_open)创建一个空的数据库,通过fork创建指定数目的子进程,等待所有子进程结束。每个子进程执行以下步骤。
(1)向数据库写nrec条记录。
(2)通过键值读回nrec条记录。
(3)执行下面的循环nrec×5次。
(a)随机读一条记录。
(b)每循环37次,随机删除一条记录。
(c)每循环11次,随机插入一条记录并读取这条记录。
(d)每循环 17 次,随机替换一条记录为新记录。在连续两次替换中,一次用同样大小的记录替换,一次用比以前更长的记录替换。
(4)将此子进程写的所有记录删除。每删除一条记录,随机地查找10条记录。
DB结构的cnt_xxx变量记录对数据库进行的操作数,这些变量的值在函数中增加。每个子进程的操作数一般都会与其他子进程不一样,因为每个子进程用来选择记录的随机数生成器是根据其进程ID来初始化的。每个子进程操作的典型计数值见图20-6。
读取的次数大约是存储和删除的10倍,这可能是许多数据库应用程序的典型情况。
每一个子进程只对该子进程所写的记录执行这些操作(读取、存储和删除)。由于所有的子进程对同一个数据库进行操作(虽然对不同的记录),所以会使用并发控制。数据库中的记录总数与子进程数成比例。(当只有一个子进程时,一开始有nrec条记录写入数据库;当有两个子进程时,一开始有nrec ×2条记录写入数据库,依此类推。)
通过运行测试程序的3个不同版本来比较加粗粒度锁和加细粒度锁提供的并发,并且比较3种不同的加锁方式(不加锁、建议性锁和强制性锁)。第一个版本使用 20.8 节中的源代码,称为细粒度锁版本。第二个版本通过改变加锁调用而使用粗粒度锁,20.6节对此已介绍过。第三个版本将所有加锁例程均去掉,这样可以计算出加锁的开销。通过改变数据库文件的权限标志位,还可以使第一个版本和第二个版本(加细粒度锁和加粗粒度锁)使用建议性锁或强制性锁(本节所有的测试中,仅对加细粒度锁的实现测量了采用强制性锁的时间)。
图20-6 每个子进程操作的典型计数值
本节所有的测试都是在一台运行Linux 3.2.0的Intel Core-i5系统上运行的。这个系统拥有4个内核,因此可以允许至多4个进程并发运行。
1.单进程的结果
图20-7显示了只有一个子进程运行的结果,nrec分别为2 000、6 000和12 000。
图20-7 单子进程、不同的nrec和不同的加锁方法
最后12列显示的是以秒为单位的时间。在所有的情况下,用户CPU时间加上系统CPU时间都基本上等于时钟时间。这一组测试受CPU限制而不是受磁盘操作限制。
中间6列(建议性锁)对加粗粒度锁和加细粒度锁的结果基本一样。这是可以理解的,因为对于单个进程来说加粗粒度锁和加细粒度锁并没有区别,除了额外的fcntl调用。
比较不加锁和加建议性锁,可以看到加锁调用在系统CPU时间上增加了32%~73%。即使这些锁实际上并没有使用过(因为只有一个进程运行),fcntl 系统调用仍会有一些时间的开销。用户CPU时间对4种不同的加锁方法基本上一样,这是因为用户代码基本上是一样的(除了调用fcntl的次数有些不同)。
关于图20-7要注意的最后一点是强制性锁比建议性锁增加了13%~19%的系统CPU时间。由于对加强制性细粒度锁和加建议性细粒度锁的调用次数是一样的,所以增加的系统开销来自读和写。
最后的测试是有多个子进程的不加锁的程序。与预期的一样,结果是随机的错误。一般错误情况包括:添加到数据库中的记录找不到、测试程序异常退出等。几乎每次运行测试程序,都有不同的错误发生。这是典型的竞争条件—多个进程在没有任何加锁的情况下修改同一个文件,错误情况不可预测。
2.多进程的结果
下一组测试主要目的是比较粗粒度锁和细粒度锁的不同。前面说过,由于加细粒度锁时数据库的各个部分被锁住的时间比加粗粒度锁少,所以从直觉上说,加细粒度锁应该能提供更好的并发性。图20-8显示了nrec取2 000,子进程数从1~16的测试结果。
图20-8 nrec=2000时不同加锁方法的比较
所有的用户时间、系统时间和时钟时间的单位均为秒。所有这些时间均是父进程与所有子进程的总和。关于这些数据有许多需要考虑。
首先要注意的是,当使用多进程时,用户时间和系统时间之和超过了时钟时间。乍看起来这有点奇怪,不过当采用多核时是正常的。此时,所有并发的进程在运行时其时间会累积起来;所显示的CPU处理时间是程序运行的所有核运转的时间之和。因为可以并发多个进程(每个核运行一个进程),所以CPU处理时间会超过时钟时间。
第 8 列(标记为“Δ时钟”),是加建议性粗粒度锁与加建议性细粒度锁的运行时钟时间的百分比差。从中可以看到使用细粒度锁得到了多大的并发性。在运行测试的系统上,对于单一进程加粗粒度锁与加细粒度锁相比效果几乎相同。而对于多进程,使用粗粒度锁的时间消耗会增大(约30%)。
我们希望从粗粒度锁到细粒度锁时钟时间会减少,当启用多进程后结果也确实如此。然而,我们预期当对任意数量的进程使用细粒度锁时系统时间仍然会保持较高值,因为使用细粒度锁会发出更多的fcntl调用。如果将图20-6中的fcntl调用次数加在一起,会发现对于粗粒度锁其平均值为87 858,对于细粒度锁其平均值为115 520。基于此,我们认为由于增加了31%的fcntl调用,所以会增加细粒度锁的系统时间。然而,在测试中加细粒度锁的两个进程其系统时间减少了,超过两个进程的系统时间只有小幅增加,这让人困惑。
出现这种情况有两个原因。首先,图 20-7 显示,当没有对锁进行竞争时,粗粒度锁和细粒度锁的时间之间没有显著的差别。这说明对于额外的fcntl调用所引起的CPU负载并没有影响测试程序的性能。其次,使用粗粒度锁时,持有锁的时间较长,这也就增加了其他进程因等待该锁而陷入阻塞的可能性;而使用细粒度锁时,加锁的时间较短,进程被阻塞的可能性就降低了。如果计算 fcntl 的阻塞次数,会发现在使用粗粒度锁时,进程阻塞频率更高。例如,当有 4 个进程时,使用粗粒度锁的阻塞次数几乎是使用细粒度锁的阻塞次数的5倍。正是这些粗粒度锁需要休眠和唤醒进程的额外时间增加了系统时间,最终降低了两种锁的系统时间差异。
最后一列(标记为“△系统”),是从加建议性细粒度锁到加强制性细粒度锁的系统 CPU时间百分比的增量。从这些值可以看到,随着并发数的增加,强制性锁显著增加了系统时间(20%~76%)。
由于所有这些测试的用户代码几乎一样(对加建议性细粒度锁和强制性细粒度锁增加了一些fcntl调用),因此预期对每一行的用户CPU时间应基本一样。
当我们第一次运行这些测试时,测试显示对于多进程完成锁的使用,其粗粒度锁的用户时间几乎是细粒度锁的两倍。因为两个数据库版本是相同的,除了调用 fcntl 的次数不同,因此这说不通。在调查研究之后,我们发现使用粗粒度锁时会有更多的竞争,进程也就会等待更久,操作系统于是就决定降低CPU时钟频率来节约电量。在使用细粒度锁时,会有更多的活动,于是系统提高了 CPU 时钟频率。这使得使用粗粒度锁比使用细粒度锁运行得慢。在禁用系统频率调整特性后,我们的测试结果就没有这些偏差了,用户时间的差别也就小多了。
图20-8的第一行与图20-7中的nrec取2 000的那一行很相似。这与预期一致。
图20-9是图20-8中加建议性细粒度锁的数据图。我们绘制了进程数从1~16的时钟时间,也绘制了用户CPU时间除以进程数后的每进程用户CPU时间,另外还绘制了每进程系统CPU时间。
注意,这两个每进程CPU时间都是线性的,但时钟时间是非线性的。可能的原因是:当进程数增大时,操作系统用于进程切换的CPU时间增多。操作系统的开销会增加时钟时间,但不会影响单个进程的CPU时间。
用户 CPU 时间随进程数增加的原因可能是因为数据库中有了更多的记录。每一条散列链更长,所以_db_find_and_lock函数平均要运行更长时间来找到一条记录。
图20-9 图20-8中使用建议性细粒度锁的数据
本章详细介绍了一个数据库函数库的设计与实现。考虑到篇幅,这个函数库尽可能小和简单,但也包括了多进程并发访问需要的对记录加锁的功能。
此外,还使用不同数量的进程以及不同的加锁方法:不加锁、建议性锁(细粒度锁和粗粒度锁)和强制性锁,研究了这个函数库的性能。可以看到加建议性锁比不加锁在时钟时间上增加了29%~59%,加强制性锁比加建议性锁耗时再增加约15%。
20.1 在_db_dodelete 中使用的加锁是比较保守的。例如,如果等到真正要用空闲链表时再加锁,则可获得更大的并发性。如果将调用 writew_lock 移到调用_db_writedat 和_db_readptr之间会发生什么呢?
20.2 如果db_nextrec不对空闲链表加读锁而被读的记录正在被删除,描述在怎样的情况下, db_nextrec 会返回正确的键但是空的(不正确的)数据记录。(提示:查看_db_dodelete。)
20.3 20.8节的结尾部分描述了_db_writeidx和_db_writedat的加锁。我们说过这种加锁不会干涉除了调用 db_store 之外的其他的读进程和写进程。如果改为强制性锁,这还成立吗?
20.4 怎样把fsync集成到这个数据库函数库中?
20.5 在db_store中,先写数据记录,然后再写索引记录。如果将顺序颠倒,会发生什么?
20.6 建立一个新的数据库并写入一些记录。写一个程序调用db_nextrec来读数据库中的每条记录,并调用_db_hash来计算每条记录的散列值。根据每条散列链上的记录数画出直方图。_db_hash中的散列函数是否能满足需求?
20.7 修改数据库函数,使得索引文件中散列链的数目可以在数据库建立时指定。
20.8 比较两种情况下数据库函数的性能:(a)数据库与测试程序在同一台机器上;(b)数据库与测试程序在不同的机器上,经由NFS进行访问。这个数据库函数库提供的记录锁机制还能工作吗?
20.9 只有当键缓冲区和数据缓冲区与其所需的大小精确匹配时,数据库才会返回空闲链表记录。请修改数据库以使空闲链表可以使用于较大的缓冲区来满足需求。应该如何更改数据库的永久格式来支持这种特性呢?
20.10 在实现了习题20.9的方案后,编写一个工具以使数据库格式可以从一种转换为另一种。
现在我们开发一个能够与网络打印机通信的程序。这些打印机通过以太网与多个计算机互联,并且通常既支持纯文本文件也支持PostScript文件。尽管一些应用程序也支持其他通信协议,但一般使用网络打印协议(Internet Printing Protocol,IPP)与打印机通信。
我们将描述两个程序:打印假脱机守护进程(print spooler daemon)将作业发送到打印机;命令行程序将打印作业提交到假脱机守护进程。因为假脱机守护进程必须处理很多操作(与客户端通信来提交作业、与打印机通信、读文件、扫描目录等),这就提供了一个机会来使用前面章节所提到的函数。例如,使用线程(第11章和第12章)来简化假脱机守护进程的设计,使用套接字(第16章)在调度文件打印的程序和打印假脱机守护进程之间通信,也可以在打印假脱机守护进程与网络打印机之间通信。
网络打印协议(IPP)为建立基于网络的打印系统指定了通信规则。通过将一个IPP服务器嵌入到带网卡的打印机中,打印机就能够对许多计算机系统的请求加以服务。这些计算机系统实际上并不需要在同一个物理网络中。因为IPP是建立在标准的因特网协议上的,所以任何一台能够与打印机建立TCP/IP连接的计算机都能向打印机提交打印作业。
IPP 由一系列 IETF 标准文档(Requests For Comment,RFC)说明,这些文档可以在http://www.ietf. org/rfc.html 上获得。IEEE 相关的打印机工作组(Printer Working Group)制定的标准草案也可以在http://www.pwg.org/ipp上获得。图21-1列出了IPP的主要文档,还有许多其他文档进一步说明了过程管理、作业属性等信息。
图21-1 基本的IPP文档
候选标准5100.12-2100指明实现提供的所有功能都要能够支持符合不同的IPP标准版本。有许多建议性的IPP协议扩展(具体的功能在IPP相关文档中定义)。将这些功能分组创建出不同的一致性分级;每一级是一个不同的协议版本。对于兼容性,每个更高的一致性级别要符合低版本定义的大多数要求。本章的示例中使用的是IPP 1.1版本。
IPP建立在超文本传输协议(Hypertext Transfer Protocol,HTTP)之上(21.3节)。HTTP又建立在TCP/IP之上。IPP报文的结构如图21-2所示。
图21-2 IPP报文结构
IPP是请求响应协议。客户端发送请求到服务器,服务器用响应报文回答这个请求。IPP首部包含一个域来指示所需操作,这些操作可以定义成提交打印作业、取消打印作业、获取作业属性、获取打印机属性、暂停和重启打印机、挂起一个作业和释放一个挂起的作业。
图21-3显示了一个IPP首部的结构。前两个字节表示IPP版本号,对于1.1版本协议,每个字节的值是 1。对于一个请求协议,接下来两个字节包含一个值来指示请求操作的类型。对于一个响应协议,这两个字节包含一个状态码。
图21-3 IPP首部结构
接下来4字节包含一个整数以标识请求,使得请求和响应相匹配。接着是可选的属性,然后用属性结束标志终止。紧接着属性结束标志之后是任何与请求相关联的数据。
在首部,整数以有符号二进制补码以及大端字节序(即网络字节序)方式存储。属性按照组来存储。每个组都以标识该组的一个字节开始。在每一个组中,属性通常表示为:1字节的标志,然后是2字节属性名长度,接着是属性名,然后是2字节属性值长度,最后是属性值本身。属性值可以编码成字符串、二进制整数或者更为复杂的结构,如日期/时间戳。
图21-4显示了attributes-charset属性是如何编码成utf-8类型的值的。
图21-4 IPP属性编码样例
根据所请求的操作,一些属性需要在请求报文中提供,而另一些是可选的。例如,图12-5显示了用于为打印作业请求定义的属性。
图21-5 打印作业请求的属性
IPP首部包含了文本和二进制混合数据。属性名存储为文本,而数据大小存储为二进制整数。这使得构建和分析首部的过程变得复杂,因为需要考虑诸如网络字节序、主机处理器是否在任意字节边界编址对齐之类的问题。一个较好的可选方案是将首部设计成仅包含文本。这样以稍微膨胀一些协议报文为代价简化处理过程。
HTTP V1.1由RFC 2616说明。HTTP也是请求响应协议。请求报文包含的一个开始行,跟着是首部行,接着是空白行,然后是一个可选的实体主体。在我们这种情况,实体主体包含IPP首部和数据。
HTTP首部是ASCII码,每行以回车(\r)和换行符(\n)结束。开始行包含一个method来指示客户端请求的操作、一个统一资源定位符(Uniform Resource Locator,URL)来描述服务器和协议、一个字符串来表示HTTP版本。IPP所用的方法仅为POST,用于将数据发送到服务器。
首部行指定属性,如实体主体的格式和长度。一个首部行包含一个属性名,后紧随一个冒号,接着是可选的空格符,然后是属性值,最后以回车和换行符结束。例如,为了指定实体主体包含IPP报文,应包含如下的首部行:
Content-Type: application/ipp
下面是对于作者使用的Xerox Phaser 8560打印机的打印请求的HTTP首部样例。
POST /ipp HTTP/1.1^M
Content-Length: 21931^M
Content-Type: application/ipp^M
Host: phaser8560:631^M
^M
Content-Length行指明了HTTP报文中数据的字节大小。这个长度不包含了HTTP首部的大小,但包括IPP首部的大小。Host行指明了要发送报文的服务器主机名称和端口号。
每行后面的^M是换行符前的回车符。换行符不能被显示成可打印字符。注意,首部的最后一行是空的,只有回车和换行符。
HTTP 响应报文的起始行包含了版本字符串,紧接着的是一个数字状态码和状态信息,最后以一个回车和换行结束。HTTP 响应报文的剩余部分和请求报文的格式一样:首部之后是一个空白行和可选的实体主体。
打印机需要发送给我们如下的报文作为打印请求的回应:
HTTP/1.1 200 OKˆM
Content-Type: application/ippˆM
Cache-Control: no-cache, no-store, must-revalidateˆM
Expires: THU, 26 OCT 1995 00:00:00 GMTˆM
Content-Length: 215ˆM
Server: Allegro-Software-RomPager/4.34ˆM
ˆM
对于打印假脱机守护进程,我们只关心报文的第一行:它说明了请求成功或者用数字错误码以及一个短字符串表示请求失败。剩下的报文包含了附加信息,可以通过在客户端和服务器间的节点来控制缓存以及表明运行在服务器上的软件版本号。
本章中我们开发的程序是一个基本的打印假脱机守护进程。一个简单的用户命令发送一个文件到打印假脱机守护进程;假脱机守护进程将其保存到磁盘,将请求送入队列,最终将文件发送到打印机。
所有的UNIX系统至少提供一个打印假脱机系统。FreeBSD安装的是BSD的打印假脱机系统LPD(参见lpd(8)和Stevens [1990]第13章)。Linux和Mac OS X包括CUPS,即Common UNIX Printing System(参见cupsd(8))。Solaris提供标准的System V打印假脱机守护进程(参见lp(1)和lpsched(1M))。在本章中,我们的兴趣不在于这些假脱机系统本身,而是如何与网络打印机通信。我们需要开发一个假脱机系统能够解决多用户访问单一资源(打印机)问题。
我们使用一个简单的命令行程序读取一个文件,将其送到打印假脱机守护进程。这个命令行程序由一个选项来强制将文件按照文本来处理(默认是PostScript文件)。这个命令行程序是print。
在我们的打印假脱机守护进程printd中,使用多线程将任务分解给守护进程来完成。
•一个线程在套接字上监听从运行print的客户端发来的新打印请求。
•对于每个客户端产生一个独立的线程,将要打印的文件复制到假脱机区域。
•一个线程与打印机通信,一次发送一个队列中的作业。
•一个线程处理信号。
图21-6显示如何将这些组件整合在一起。
打印配置文件是/etc/printer.conf。这个文件标识了运行打印假脱机守护进程的服务器主机名和网络打印机的主机名。以 printserver 关键字开始的行标识了假脱机守护进程。以printer关键字开始的行标识了打印机,空格符之后跟着打印机的主机名。
图21-6 打印假脱机组件
一个打印机配置文件样例可能包含下列行:
printserver fujin
printer phaser8560
其中fujin是运行打印假脱机守护进程的计算机系统主机名,phaser8560是网络打印机的主机名。我们假设这些名字已经在/etc/hosts中列出或者已经通过正在使用的任意服务进行了注册,这样我们就可以将这些名字转换成网络地址。
可以在运行打印假脱机守护进程的同一台机器上运行 print 命令,也可以在同一个网络中的任意机器上运行它。我们只需配置在/etc/printer.conf 中的 printserver 字段即可,因为只有守护进程需要知道打印机名称。
安全
拥有超级用户特权的程序可能让计算机系统受到攻击。这些程序通常并不比其他程序更脆弱,但是被攻破时将导致攻击者能够完全访问你的计算机系统。
本章中的打印假脱机守护进程拥有超级用户特权,在这个例子中能够将一个特权TCP端口号绑定一个套接字。为了使守护进程能更好地抵御攻击,我们可以:
•按照最少特权的原则(8.11 节)设计守护进程。我们获得一个绑定到特权端口的套接字之后,可以将守护进程的用户ID和组的ID更改为非root(如lp)。所有用于存储队列中打印作业的文件和目录的拥有者应该是非特权用户。如果被攻击,这种情况下攻击者只能通过守护进程访问打印子系统。虽然这仍然是一个隐患,但是比起攻击者可以完全访问系统,其危害性已大大降低了。
•审计守护进程源代码中所有已知的潜在脆弱性漏洞,如缓冲区溢出。
•对不期望或者可疑的行为做日志,这样可以引起管理员注意并进一步调查。