简介
异步操作(asynchronous operation)
- 是由于很多计算机系统事件会在不可预测的事件,以不可预测的顺序发生而产生的
并发(concurrency)
- 是指在相同的时间帧内对资源的共享
- 并发实体,可以是单个程序内部的执行线程或者其他抽象的对象
- 并发可能发生在单CPU系统,共享相同内存的多CPU系统,或者运行在网络上的独立系统中
通信(communication)
- 将一个实体的信息传送给另一个实体
- 程序在处理磁盘这样的本地设备的I/O的同时,还必须要处理网络I/O(网络通信)
上下文切换时间(context-switch time)
- 是指从执行一个进程转换到执行另一个进程所花费的时间
时间片(quantum)
- 大致上就是在一个进程不得不让出处理器让其他进程运行之前,分配给这个进程的CPU时间总量
多道程序设计(multiprogramming)
指由于处理时间上的悬殊差异,采取有多个进程准备好要执行,操作系统挑选一个已经准备好的进程来执行,当哪个进程需要等待资源时,操作系统保存从停止处回复此进程所需的所有信息,并选择另一个准备好的进程执行
一次资源请求会引起一次对操作系统的请求(即一次系统调用)
系统调用(system call)
- 是对操作系统服务的一次请求,它会使正常的CPU周期中断,并将控制权交给操作系统,然后,操作系统就可以切换到另一个进程了
分时(timesharing)
- 单物理CPU,并发
多处理器系统(multiprocessor systems)
- 几个处理器都访问一个共享的内存
硬件层并发
- 由于有多台设备要同时操作
- 处理器中有内部的并行机制,可以同时处理几条指令,系统中有多个处理器,而且系统通过网络通信进行交互
应用层并发
- 在信号处理中,I/O与其他处理的重叠中,在通信过程中,在进程间或同一进程的不同线程间的资源共享中,都存在应用层的并发
中断(interrupt)
- 在常规机器层(conventional machine level)程序中,单指令的执行是处理器指令周期(processor instruction cycle)的结果
- 在处理器指令周期的正常执行过程中,处理器从程序计数器中检索出一个地址,并执行这个地址上的指令。
- 在常规机器层出现并发,是因为外围设备会产生一种被称为中断的电信号,在处理器内部设置一个硬件标志符。
- 检测中断是指令周期自身的一部分。在每个指令周期中,处理器都检查硬件标识。
- 如果处理器察觉有中断发生,它就保存程序计数器的当前值,并装载一个新的值,这个新的值是一个被称为中断服务例程(interrupt service routine)或中断处理程序(interupt handler)的特殊函数的地址
异步(asynchronous)
- 如果一个事件发生的时间不是由某个实体确定的,那么这个事件就是异步于这个实体的。
- (外部硬件设备产生的中断通常都异步于系统中执行的程序)
同步(synchronous)
- 如果向指令提供相同的数据,那么,像被零除这样的错误事件,就总是在执行某个特殊指令的时候发生,从这种意义上来说,错误事件是同步的
设备驱动程序(device driver)
- 被称为设备驱动程序的操作系统例程,通常用来处理外围设备产生的中断。
- 然后这些驱动程序会通过信号这样的软件机制,来通知相关的进程事件已经发生了
定时器(timer)
- 操作系统也用中断来实现分时。
- 大多数计算机都有一个被称为定时器的设备,它可以在一段指定的时间间隔后产生中断。
- 为了执行用户程序,操作系统在设备程序计数器之前启动定时器。定时器到时的时候,它就产生一个中断,使CPU转而执行定时器中断服务例程。中断服务例程将操作系统代码的地址写入程序计数器,这样,操作系统又获得了控制权
信号(signal)
- 是事件的软件通知
- 通常,信号是操作系统对中断(硬件事件)的响应
- 例如,按下
Ctrl-C
键会使处理键盘的设备驱动程序产生一个中断。驱动程序将这些字符当做中断字符,并发送信号来通知与这个中断相关的进程 - 当引发信号的那个事件发生时,信号就产生了(generate)了 – 信号可以同步产生,也可以异步产生
- 如果信号由接收它的进程或线程产生,这个信号就是同步产生的。执行非法指令都会产生同步信号
- 在键盘上输入
Ctrl-C
会产生一个异步信号
捕捉(catch)
- 进程执行信号的处理程序时,它就捕捉到了信号
- 捕捉信号的程序至少有两个并发的部分,主程序和信号处理程序
进程,线程和资源共享
- 在UNIX中实现并发执行的一种传统方法是:用户通过调用
fork()
函数创建多个进程。 - 有相同祖先的进程可以通过管道(pipe)进行通信
- 没有共同祖先的进程可以通过信号,FIFO,信号量,共享的地址空间或消息进行通信
- 在进程内部可以通过多个执行线程提供并发。
- 程序执行时,CPU用程序计数器来确定下一步要执行哪条指令。得到的指令流被称为程序的执行线程(thread of execution)。它是进程的控制流
分布式计算
- 并发和通信共同形成新的应用程序
- 在分布式计算中使用最广泛的模型是客户端-服务器模型(client-server model)。这个模型中的基本实体
- 有管理资源的服务器进程,
- 和需要对共享资源进行访问的客户机进程
- 基于对象的模型(object-based model)是分布式计算的另一种模型
- 系统中的每种资源都被看作一个带有消息处理接口的对象,这样就可以用统一的方式来访问所有的资源
- 基于对象的模型允许进行受控的增量开发和代码重用
缓冲区溢出(buffer overflow)
当程序将数据拷贝到一个没有为其分配足够空间的变量中去的时候,就会发生缓冲区溢出
缓冲区溢出的后果
- 要理解缓冲区溢出时会发生什么情况,就要理解程序在内存中是如何布局的
- 大多数程序代码都在带有自动局部变量的函数中执行
- 虽然在不同的机器上实现的细节有所不同,程序通常都在程序栈上分配自动变量
- 在典型系统中,栈都是从高端内存向低端内存扩展的
- 调用一个函数时
- 栈的低端部分包括传递的参数和返回地址
- 栈中较高的部分(内存地址比较小的部分)用来存放局部自动变量
- 栈可以用来存储其他值,也可能包含根本不为程序所用的间隙
- 一个很重要的事实是:
- 每次函数调用的返回地址通常都存储在自动变量后面的内存中(存储在地址比较大的内存中)
- 当程序向栈中变量的范围之外写入时,回复阿生缓冲区溢出。额外的字节可能会重写未使用的空间,其他变量,返回地址或该程序不能合法访问的其他内存。
- 结果可能是没什么影响,也可能会造成程序崩溃,信息转储以及不可预测的行为
- 要理解缓冲区溢出时会发生什么情况,就要理解程序在内存中是如何布局的
程序,进程和线程
程序(program)
指的是为了完成特定的任务而准备好的一个指令序列
C编译器将每个源文件翻译成一个目标文件,然后编译器将这些单个的目标文件同必须的一些库链接,形成一个可执行模块(executable module)。程序运行或执行(execute)时,操作系统将可执行模块拷贝到主存储器的程序映像(program image)中去
进程(process)
- 是一个正在执行的程序实例
- 每个实例都有自己的地址空间和执行状态
- 操作系统记录进程ID和相应的进程状态,并用这些信息来分配和管理系统资源。操作系统还要对进程占用的内存和可分配的内存进行管理
- 当操作系统向内核数据结构中添加了适当的信息,并为运行程序代码分配了必要的资源之后,程序就变成了进程。
- 程序拥有地址空间(它可以访问的内存)和至少一个被称为线程的控制流
- 进程,以执行一个指令序列的控制流开始。处理器程序计数器记录处理器(CPU)要执行的下一条指令。CPU读取一条指令后,对程序计数器的值进行增量运算,并且在指令的执行过程中,比如,在出现分支的时候,还会对其做进一步的修改。
- 可能有多个进程驻存在内存中并发地执行,他们基本上都互相独立。如果进程要进行通信或互相合作,它们就必须显式地通过文件系统,管道,共享内存或网络这样的操作系统结构来交互
线程和执行线程(thread of execution)
- 程序执行时,由进程程序计数器的值来决定下面该执行哪一条进程指令。得到的指令流被称为执行线程
- 它可以用程序代码执行期间为程序计数器指定的指令地址序列来表示
- 执行线程中的指令序列对进程来说,就像是一条不间断的地址流。但从处理器的观点来看,来自不同进程的执行线程是混在一起的。
- 执行从一个进程切换到另一个进程的点被称作上下文切换(context switch)
- 线程,是代表了进程内执行线程的一种抽象数据类型。线程有自己的执行栈,程序计数器值,寄存器组和状态
- 通常在一个进程范围内声明多个进程,程序员可以编写出以很低的开销获得并行性的程序。
- 尽管这些线程提供了低开销的并行性,但由于它们驻留在相同的进程地址空间并共享进程资源,因此,可能还需要对它们进行额外的同步。
- 由于启动进程所需要的工作量大,有些人将进程称作是重量级(heavyweight)
- 与之相反,线程有时被称作轻量级进程(lightweight processes)
程序映像的布局
- 加载之后,可执行程序看起来占据了一个连续的内存块,这个连续的内存块被称为程序映像(program image)
- 程序映像有几个不同的分区。程序文本或代码显示在内存低端地址中。在映像中已经初始化和未初始化的静态变量也有自己的分区。其他的分区包括堆,栈和环境
- 活动记录(activation record)
- 指的是在进程栈顶端分配的一个内存块,用来装载调用过程中函数的执行上下文。
- 每次函数调用都在栈上创建一个新的活动记录
- 除了静态变量和自动变量之外,程序映像中还包括了
argc
和argv
占用的空间以及malloc
分配的空间。 malloc
函数族在一个被称为堆(heap
)的空闲内存池中分配存储空间- 在堆上分配的存储空间一直存在,直到它被释放或程序退出为止
- 如果一个函数调用了
malloc
,那么在这个函数返回值后,存储空间仍保持已分配状态。 - 除非程序有一个在函数返回值后仍然可以访问的,指向该存储空间的指针,否则,返回后的程序就不能访问它
- 在声明时,没有显式初始化的静态变量在运行时被初始化为0
- 在程序映像中,已初始化的静态变量和未初始化的静态变量占据不同的分区
- 通常,已初始化的静态变量是磁盘上可执行模块的一部分,而未初始化的静态变量则不是
- 自动变量不是可执行模块的一部分,因为只有当定义它们的程序块被调用时,它们才会被分配。除非程序显式地对自动变量进行初始化,否则,它们的初始值是不确定的
- 对线程化的执行来说,静态变量会使程序变得不安全。
- 连续调用一个引用了静态变量的函数会出现意料不到的情况,因此,外部静态变量也使得代码的调试更加困难。
- 出于这些原因,除非是在受控的情况下,否则应该避免使用静态变量
- 尽管程序映像看起来占据了一个连续的内存块,但实际上,操作系统将程序映像映射到不一定连续的物理内存块中。
- 通常的映射将程序映像划分成相同大小的片,这些片被称为页(
page
)- 操作系统将这些页加载到内存中,当处理器引用某页上的内存时,操作系统会从一个表中查找这一页的物理位置。
- 这种映射方式允许栈和堆有很大的逻辑地址空间。
- 操作系统隐藏了这种底层映射的存在,这样即使有些页实际上并没有驻留字内存中,程序员也可以认为程序映像在逻辑上是连续的
函数返回值和错误
- 错误处理是编写可靠系统程序中的一个关键问题
- 处理UNIX程序中错误的标准方法有如下几种
- 打印出错误消息并退出程序(仅在main函数中)
- 返回
-1
或NULL
,并设置errno
这样的错误提示符 - 返回错误码
- 总的来说,函数永远也不能自己退出,而是应该向调用它的程序报告错误
- 函数内部的错误消息在调试阶段可能会很有用,但通常不应该出现在最终版本中。
- 处理调试信息有一种很好的方法:
- 将调试打印语句包含在一个条件编译块中,这样在需要的时候可以将其重新激活
参数数组(argument array)
- 是一个指向字符串的指针数组
- 数组的结尾由一个包含
NULL
指针的条目来标识。
静态变量的使用
- 静态变量可以用来存储函数调用之间的内部状态信息
进程环境
- 环境列表(environment list)由一个指针数组组成,其中的指针指向 名字=值(name=value) 形式的字符串。数组的最后一个条目为
NULL
- 名字,指定一个环境变量(environment variable)
- 值,指定与环境变量相关的字符串的值
- 如果进程由
execl, execlp, execv, execvp
初始化,那么进程就继承了执行exec
之前的那个进程的环境列表 - 环境变量提供了一种用系统特定信息或用户特定信息在程序内部设置默认值的机制。例如,程序可能需要在用户的主目录中写入状态信息,或者需要在特定的地方查找一个可执行文件。用户可以在一个变量中设置信息,用以说明在哪里可以找到可执行文件。应用程序用其特有的方式来解释环境变量的值
- 用
getenv()
来确定在进程环境中,一个指定的变量是否有值。将环境变量的名字作为字符串来传递 - 不要将环境变量与预定义的常量混淆
- 预定义的常量是用
#define
在头文件中定义的,它们的值是常数,在编译时是已知的,要想查看这样一个常量的定义是否存在,可以使用编译器指令#ifndef
- 与之相反,环境变量是动态的,直到运行时才能直到它们的值
- 预定义的常量是用
POSIX环境变量及其含义
COLUMNS
– 终端上列的优选宽度HOME
LINES
– 页或垂直屏幕上的优选行数LOGNAME
– 与进程相关的登录名PATH
– 用于寻找可执行文件的路径前缀PWD
– 当前工作目录的绝对路径名SHELL
– 用户优选的命令解释程序的路径名TERM
– 输出的终端类型TMPDIR
– 临时文件目录的路径名TZ
– 时区信息
进程终止
进程终止时,操作系统释放进程资源,更新适当的统计信息并向其他进程通知进程的死亡
终止可以是正常的,也可以是不正常的。进程终止期间执行的动作包括
- 取消挂起的定时器和信号
- 释放虚拟内存资源
- 释放其他进程持有的系统资源(例如锁)
- 关闭打开的文件
操作系统记录进程状态和资源的使用情况,同时通知父进程对
wait
函数进行响应在UNIX中,进程终止后不会完全释放它的资源,直到父进程等待它为止。
如果进程终止的时候,它的父进程没有等待它,那么这个进程就成为一个僵进程(zombie)。
僵进程是一个不活动的进程,它的资源会在稍后父进程等待它的时候被删除。一个进程终止时,它的孤儿子进程(orphaned child)和僵进程会被一个特殊的系统进程收养。
在传统的UNIX系统中,这个特殊的进程被称为init进程,它的进程ID值为1,并周期性地等待子进程
进程正常终止:
- 从main中return
- 从main中隐式地返回(main函数执行到末尾)
- 调用exit, _Exit或_exit
C的exit函数调用了用户定义的退出处理程序,这些处理程序是由
atexit()
按照与登记时相反的顺序记录的调用了用户定义的处理程序之后,exit对任何一个包含未写入的缓冲数据的打开的流(open stream)进行刷新,然后关系所有打开的流。最后,exit删除所有tmpfile()创建的临时文件,并终止控制进程。
Unix系统中的进程
进程标识
UNIX用唯一的被称为进程ID(process ID)的整数值来标识进程
每个进程还有一个父进程ID(parent process ID),这个父进程ID最初是创建它的那个进程的进程ID
返回进程和父进程函数:
getpid()
,getppid()
系统管理员创建用户账户时,为每个用户分配唯一的整型用户ID(user ID)和整型组ID(group ID)
系统通过用户ID和组ID从系统数据库中检索出允许这个用户使用的权限。
返回用户ID和组ID的函数:
getegid()
,geteuid()
进程状态
进程的状态(state)说明了它在某个特定时刻的状况
进程执行I/O时是通过一个库函数去请求服务的,这个库函数有时被称为系统调用(system call)
在系统调用的执行过程中,操作系统重新获得对处理器的控制权,并且可以将进程转入阻塞状态,直到操作结束为止
上下文切换(context switch),是指将一个进程从运行状态移出,并用另一个进程来替代它的行为
进程上下文(process context),是操作系统在上下文切换之后重启进程所需的,有关此进程及其环境的信息
- 很明显,就像用于静态和动态变量的内存的当前状态一样,可执行代码,栈,寄存器和程序计数器都是上下文的一部分
- 为了能够透明地重启进程,操作系统还要记录进程状态,程序I/O的状况,用户和进程的标识,权限,调度参数,账号信息以及内存管理信息
- 如果进程在等待事件或者已经捕捉到了一个信号,那么这个信息也是上下文的一部分
- 上下文还包括与其他资源相关的信息,例如进程持有的锁等
Unix进程的创建与fork调用
进程可以通过调用fork来创建新的进程
调用进程就称为父进程(parent),被创建的进程就被称为子进程(child)
fork函数拷贝了父进程的内存映像,这样新进程就会收到父进程地址空间的一份拷贝。两个进程在fork语句之后,都继续执行后面的指令(分别在它们自己的内存映像中执行)
子进程继承(inherit)了诸如环境和权限这样的父进程属性,还继承了某些父进程资源,例如打开的文件和设备
wait函数
一个进程创建子进程时,父进程和子进程都从fork后的那个点开始继续执行
父进程可以通过执行wait和waitpid一直阻塞到子进程结束
wait函数会使调用者的执行挂起,直到子进程的状态成为可用,或者调用者收到一个信号为止
exec函数
fork函数创建了调用进程的一份拷贝,但很多应用程序都需要子进程执行与其父进程不同的代码
exec函数族提供了用新的映像来覆盖调用进程的进程映像的功能
fork-exec配合应用的传统方式是:子进程(用exec函数)执行新程序,而父进程继续执行原来的代码
六种不同形式的exec函数的区别在于命令行参数和环境变量的传递方式。它们的不同还在于是否要给出可执行文件的完整的路径名
execl(execl, execlp, execle)
函数用一个显式的序列来传递命令行参数,如果在编译时就知道命令行参数的数目,这些函数是很有用的execv(execv, execvp, execve)
函数将命令行参数放在一个参数数组中传递
exec函数将一个新的可执行文件拷贝到进程映像中去。程序的文本,变量,栈和堆都被重写了
除非原始进程调用了
execle, execve
,否则新进程就继承环境(也就是说,继承了环境变量列表及其相关的值)
后台进程与守护进程
命令解释程序是一个用来提示命令,从标准输入中读取命令,创建子进程来执行命令并等待子进程执行完毕的一个命令解释程序。
当标准输入和标准输出来自于一个终端类型的设备时,用户可以通过输入中断字符来终止一个正在执行的命令
- 中断字符是可以设置的,但很多系统都假定中断字符的默认值为
Ctrl-C
- 中断字符是可以设置的,但很多系统都假定中断字符的默认值为
大多数命令解释程序将一个以
&
结束的行解释为应该由后台进程执行的命令命令解释程序创建了一个后台进程时,它在发出提示符并接受其他的命令之前不用等待进程的结束。而且,从键盘键入的
Ctrl-C
也不能终止后台进程守护进程(daemon),是一个通常能够无限期运行的后台进程
UNIX操作系统依靠很多守护进程来执行例行的任务
临界区
每个进程中对,应该一次只被一个进程使用的资源,进行访问的那部分代码都被称为临界区(critical section)
带有临界区的程序必须要注意不能违反互斥(mutual execlusion)的原则
提供互斥的一种办法是使用锁机制
为了减少内部交互的复杂性,有些操作系统使用了面向对象(object-oriented)的设计。
共享的表和其他资源都被封装成对象,这些对象都带有规定的很明确的访问函数。访问这样一个表的唯一的方法就是使用这些函数,这些函数都内建了恰当的互斥
在分布式系统中,对象接口都使用消息
从表面上看,面向对象的方法与守护进程类似,但从结构上看,这些方式可能会有很大的不同。
- 守护进程并不一定要封装资源。它们可以以一种不受控的方式来争夺共享的数据结构
- 好的面向对象设计保证了数据结构是被封装的,并且只能通过精心控制的接口对其进行访问。
- 守护进程可以用面向对象的设计来实现,但并不一定非要这样实现。
UNIX I/O
- UNIX通过文件描述符来实现统一的设备接口,这种统一的接口允许为终端,磁盘,磁带,音频甚至网络通信使用相同的I/O调用
设备术语
外围设备(peripheral device)是指计算机系统访问的硬件。
- 常见的外围设备包括磁盘,磁带,CD-ROM,显示器,键盘,打印机,鼠标和网络接口
用户程序对这些设备的控制和I/O操作是通过对被称为设备驱动程序(device driver)的操作系统模块所进行的系统调用来实现的。
- 设备驱动程序将设备操作的细节隐藏起来,并保护设备以免其受到未授权的使用
有些操作系统为它所支持的每种类型的设备都提供了特定的系统调用,这就要求系统程序员掌握一组复杂的设备控制调用
UNIX为大多数设备提供了标准的访问接口,这就极大地简化了提供给程序员的设备接口
UNIX对设备的标准访问接口是通过5个函数来实现的:
open, close, read, write, ioctl
所有的设备都用文件来表示,这些文件被称为特殊文件(special file),存放在目录
/dev
中因此,磁盘文件和其他设备都用统一的方式来命名和访问
- 正常文件(regular file)只是磁盘上一个普通的数据文件
- 块特殊文件(block special file)表示特性和磁盘类似的设备。磁盘驱动程序以块或组块的形式从块特殊设备中传送信息,而且这些设备通常都具有从设备的任何地方检索块的能力
- 字符特殊文件(character special file)表示特性与终端类似的设备。这些设备看起来表示的是一串必须按顺序访问的字节流
读和写
UNIX通过read和write函数提供了对文件和其他设备的顺序访问
- read函数试图从用
fildes
表示的文件或设备中取出nbyte
字节,并将其放入用户变量buf
中去。
- read函数试图从用
文件描述符,表示了打开的文件或设备,可以将文件描述符想象成进程文件描述符表的索引
文件描述符表,在进程的用户区中,提供了对相关文件或设备的系统信息的访问
从命令解释程序中执行一个程序时,程序的启动伴随着三个与文件描述符
STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO
相关的打开的流STDIN_FILENO, STDOUT_FILENO
分别为标准输入和标准输出。默认情况下,这两个流通常对应于键盘输入和显示器输出- 程序应该为错误消息使用标准错误设备
STDERR_FILENO
,且永远也不应该将其关闭
readblock(), r_write()
打开和关闭文件
open函数将一个文件描述符(程序中使用的句柄)与一个文件或物理设备关联起来
每个文件都有三个与之相关的类:用户(或所有者),组和所有其他人(其他的人)
可能的权限或者特权有读(r),写(w)和执行(x)
分别独立地为用户,组和其他人指定这些特权
select函数
对来自不同源端的I/O的处理是一个很重要的问题,它可能以多种不同的形式出现
保持阻塞状态,直到一组条件中至少有一个条件为真为止,这种方法被称为或同步(OR synchronization)
描述的情况中的条件是:描述符上的“输入可用(input available”
监视多个文件描述符的一种方法是为每个描述符分别使用一个独立的进程
用独立的进程来监视两个文件描述符可能很有用,但是这两个进程都有独立的地址空间,因此它们之间的交互很困难
select()
调用提供了一种在单个进程中监视多个文件描述符的办法它可以对三种可能的状况进行监视:
- 可以无阻塞地进行的读操作
- 可以无阻塞地进行的写操作
- 有挂起的错误情况的文件描述符
poll函数
poll函数与select类似,但它是用文件描述符而不是条件的类型来组织信息的
也就是说,一个文件描述符的可能事件都存储在struct pollfd中
与之相反,select用事件的类型来组织信息,而读,写和错误情况都有独立的描述符掩码
poll函数有三个参数:
fds, nfds, timeout
- fds, 是一个struct polldf数组,用来表示文件描述符的监视信息
- nfds,给出了要监视的描述符的数目
- timeout,是一个用毫秒表示的事件,是poll在返回前没有接收事件时应该等待的时间
- 如果timeout的值为-1, poll就永远都不会超时
- 如果整数值为32个比特,那么最大的超时周期大约为30分钟
返回值:
- 如果超时,poll函数返回0
- 如果成功,poll返回拥有事件的描述符的数目
- 如果不成功,poll返回-1并设置errno
文件表示
在C程序中,文件由文件指针或文件描述符来指定
ISO C的标准I/O库函数(
fopen, fscanf, fprintf, fread, fwrite, fclose
and so on)使用文件指针UNIX的I/O函数(
open, read, write, close, ioctl
)使用文件描述符文件指针和文件描述符提供了用来执行独立于设备的输入和输出的逻辑标识,这些逻辑标识被称为句柄(handle)
代表标准输入,标准输出和标准错误的文件指针的符号名分别为
stdin, stdout, stderr
,这些符号名定义在stdio.h
中。代表标准输入,标准输出和标准错误的文件描述符的符号名分别为
STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO
,这些符号名定义在unistd.h
中
库函数和系统调用之间的区别
POSIX标准不区分库函数和系统调用。
传统上,库函数是一个普通的函数,通常因为它有用,得到广泛的应用或者是C这样的规范的一部分,而被放在一个被称为库的函数集合中。
系统调用是对操作系统发出的服务请求。它包含了对操作系统的自陷(trap),通常还包含上下文切换
系统调用与特定的操作系统相关。
很多的库函数,例如read和write,实际上都是系统调用的外套(jacket)。
也就是说,它们以恰当的,与系统相关的形式重新设置参数的格式,然后调用底层的系统调用来执行实际的操作
文件描述符
- open函数将文件或物理设备与程序中使用的逻辑句柄相关联。用字符串(例如
/home/user/my.data
)来指定文件或物理设备。 - 句柄是一个整数,可以将其理解为进程特定的文件描述符表(file descriptor table)的索引
- 对进程中每个打开的文件,文件描述符表都包含一个相应条目,文件描述符表是进程用户区的一部分,但是除非通过使用文件描述符的函数,否则程序无法对其进行访问
文件指针和缓冲
ISO C标准的I/O库用文件指针而不是文件描述符作为I/O的句柄
文件指针(file pointer)指向进程用户区中的一个被称为FILE结构的数据结构
FILE结构包括一个缓冲区和一个文件描述符值
文件描述符值是文件描述符表中条目的索引,实际上就是通过这个文件描述符表将文件输出到磁盘的
从某种意义上说,文件指针就是句柄的句柄
磁盘文件通常都是完全缓冲(fully buffered)的,这就意味着,
fprint
实际上没有将消息写入磁盘,而是将这些字节写入了FILE结构的一个缓冲区里。缓冲区填满时,I/O子系统就会用文件描述符来调用write。
文件执行
fprintf
的时刻和实际进行写操作的时刻之间的时延可能会造成意外的结果,尤其是在程序崩溃的时候。系统崩溃时,有时会丢失缓冲的数据,因此,甚至会出现:程序看起来是正常结束了,但它的磁盘输出确实不完整的
程序怎样才能避免缓冲对它的影响?
fflush调用会强制写出FILE结构中缓冲的任何内容
程序也可以调用
setvbuf
来禁止缓冲终端I/O的工作方式不同,与终端相关的文件是行缓冲(line buffered)的,而不是完全缓冲的(标准错误除外,它在默认情况下是不缓冲的)
对输出来说,行缓冲意味着在缓冲区被填满或遇到一个新行符号之前,行不会被写出
过滤器和重定向
UNIX提供了大量作为过滤器而编写的工具
过滤器(filter)从标准输入中读入,执行一个转换,然后将结果输出到标准输出中去
过滤器将它们的错误消息写入标准错误
过滤器所有的参数都作为命令行参数传送
输入数据不应该有首部或尾部,而且过滤器也不应该要求与用户进行任何交互
实用的UNIX过滤器的例子包括:
head, tail, more, sort, grep, awk
cat命令将一个文件名列表作为命令参数,一个接一个地读其中的每个文件,并将每个文件的内容会送到标准输出中去
但是,如果没有指定输入文件,cat就会从标准输入获取它的输入,并将结果写入标准输出。在这种情况下,cat表现得像过滤器一样
文件描述符是那个进程的文件描述符表的一个索引。文件描述符表中的每个条目都指向系统文件表中的一个条目,该条目是在文件被打开时创建的。
程序可以对文件描述符表的条目进行修改,使其指向系统文件表中的另一个条目。这种动作叫做重定向(redirection)
大多数命令解释程序豆浆命令行中的大于字符(>)解释成对标准输出的重定向,而小于字符(<)解释成对标准输入的重定向
dup2函数有两个参数:fildes, fildes2
文件控制
fcntl()
函数是一个通用函数,可用来检索和修改与打开的文件描述符相关联的标志符fcntl()
参数fildes
指定了描述符,参数cmd
指定了操作
文件和目录
操作系统将原始存储设备以文件系统的形式组织起来,这样应用程序就可以用高级操作,而不是低级的设备调用来访问信息
UNIX文件系统是树形的,节点表示文件,弧线表示包含关系
UNIX目录项将文件名与文件所在位置关联起来。
这些目录项可以直接指向一个包含文件位置信息的结构(硬链接)
也可以通过符号链接间接地指向文件所在位置
- 符号链接是将一个文件名关联到另一个文件名的文件
UNIX文件系统导航
文件系统(file system),是文件和属性的集合,其中的属性包括位置和名字等
应用程序不用指定文件在磁盘上的物理位置,而只需指定文件名和偏移量。操作系统通过它的文件系统将其翻译为物理文件的位置
目录,是一个包含了目录项(directory entry)的文件,目录项将文件名与文件在磁盘上的物理位置关联起来
绝对路径名(absolute pathname)或全称路径名(fully qualified pathname),指定了文件系统树中从根到文件自身的路径上所有的节点
程序不一定总要用全称路径名来制定文件。
任何时候,每个进程都有一个用来作路径名解析的相关目录,这个目录被称作当前工作目录(current working directory)
pathconf
函数,是允许程序以一种与平台无关的方式来确定系统和运行期极限的函数族中的一个
目录访问
目录不能用普通的
open, close, read
函数来访问。相反,访问目录需要使用特定的函数,相应的函数名以
dir
结束:opendir, closedir, readdir
opendir()
函数,为一个目录流提供了DIR*
类型的句柄,该流的当前位置就在目录的第一项上定义在dirent.h中的DIR类型,表示的是一个目录流(directory stream)
目录流是一个特定目录中所有目录项组成的一个有序序列。目录流中的条目不一定是按文件名的字母顺序排列的
readdir()
函数,是通过返回dirp所指向的目录流中的连续条目来读取目录的readdir()
在每次调用之后都将流转移到下一个位置上去closedir()
函数,关闭一个目录流,而rewinddir()
函数把目录流重新定位在起始处每个函数都有一个参数
dirp
,这个参数对应于打开的目录流
访问文件状态信息
fstat()
函数,用打开的文件描述符来访问文件stat()
和lstat()
函数通过名字来访问文件- 它们都有两个参数,参数
path
指定了需要返回状态的文件或符号链接的名字 - 如果path不对应于符号连接,也就是文件,它们就返回相同的结果
- 当path是一个符号链接时
lstat()
函数返回与链接有关的信息,stat()
函数返回与链接所指向的文件有关的信息
- 参数
buff
指向一个用户提供的缓冲区,这些函数都将信息存储在这个缓冲区中
- 它们都有两个参数,参数
确定文件的类型
- 文件模式成员
st_mode
指定了文件的访问权限和文件的类型 - POSIX规定用不同宏来测试不同文件类型的
st_mode
成员- S_ISBLK(m) – 块特殊文件
- S_ISCHR(m) – 字符特殊文件
- S_ISDIR(m) – 目录
- S_ISFIFO(m) – 管道或FIFO特殊文件
- S_ISLNK(m) – 字符链接
- S_ISREG(m) – 正常文件
- S_ISSOCK(m) – 套接字
- S_TYPEISMQ(buf) – 消息队列
- S_TYPEISSEM(buf) – 信号量
- S_TYPEISSHM(buf) – 共享的内存对象
- 测试文件类型的POSIX宏
m
的类型为mode_t
, buf的值是一个指向struct stat
结构的指针
UNIX文件系统的实现
- 磁盘格式化将物理磁盘分隔成被称为分区(partition)的区域
- 每个分区都可以有自己的文件系统与之相关联
UNIX文件的实现
目录项中包含一个文件名以及对一个定长结构的引用,这个定长结构被称作索引节点(inode)
索引节点中包括了与文件长度,文件位置,文件所有者,创建时间,最后访问时间,最后修改时间,权限等有关的信息
块(block),可以表示不同的含义(甚至在UNIX系统内部也是如此)。
在这里,块通常是8K字节,一个块中的字节数通常是2的幂
POSIX不要求系统真的采用索引节点来表示它的文件
struct stat
的成员ino_tst_ino
,现在被称为文件序列号(file serial number),而不是索引节点号(inode number)
硬链接和符号连接
UNIX目录中有两种类型的链接:链接和符号链接
链接,是指一个目录项,有时也被称为硬链接(hard link),目录项可以将文件名与文件位置关联起来
符号链接(symbolic link),有时也称为软连接(soft link),是指存储了一个字符串的文件。如果在路径名解析过程中遇到了这个字符串,就用它来修改路径名
(硬)链接的创建与删除
link()
函数为path1
指定的已存在文件创建一个新的目录项,这个文件位于path2
指定的目录中函数原型:
int link(const char *path1, const char *path2)
头文件:
#include <unistd.h>
unlink()
函数删除了path指定的目录项函数原型:
int unlink(const char *path);
头文件:
#include <unistd.h>
符号链接的创建与删除
符号链接,是一个包含了另一个文件或目录名字的文件。
引用符号链接的名字会使操作系统去定位对应于那个链接的索引节点
操作系统假设相应的索引节点的数据块中包含另一个路径名。然后,操作系统对那个路径名的目录项进行定位,并继续跟踪这个链接,直到最终遇到一个硬链接和一个真正的文件为止。
如果系统过了一段时间还没有找到真正的文件,它就放弃并返回ELOOP错误
symlink()
函数创建一个符号链接,参数path1包含了将成为链接的内容的字符串,path2给出了链接的路径名。- 换句话来说,path2就是新创建的链接,而新链接指向path1
函数原型:
int symlink(const char *path1, const char *path2);
头文件:
#include <unistd.h>
du
- UNIX的命令解释程序命令
du
是POSIX:UP
扩展的一部分 - 命令用来显示树的子目录的大小,这个树的根位于它的命令行参数指定的目录中
- 如果调用时未带目录,du工具就使用当前的工作目录
UNIX特殊文件
- 管道和FIFO是这些特殊文件的两种重要实例。
- 它们是进程间通信机制,这些机制使得运行在同一个系统中的进程可以共享信息,从而互相合作
管道
对那些需要相互配合来解决问题的进程来说,通信是一种必备的能力
最简单的UNIX进程间通信机制是管道,管道由特殊文件来表示
pipe()
函数创建了一个通信缓冲区,程序可以通过文件描述符fildes[0]
和fildes[1]
来访问这个缓冲区写入
fildes[1]
的数据可以按照先进先出的顺序从fildes[0]
中读出使用管道的单个进程并没有很大的用处。
通常父进程会用管道来与它的子进程进行通信
流水线
- 垂直线
|
表示一个管道 - 管道就像进程间的缓冲区一样,允许进程以不同的速度读和写。read和write的阻塞性本质有效地同步了进程
FIFO
没有进程打开管道时,管道就消失了,从这个意义上来说,管道是临时的
POSIX用特殊文件来表示FIFO或命名管道(named pipe),这些特殊文件在所有的进程都将其关闭之后仍然存在
FIFO像普通文件一样,有名字和访问权限,而且会出现在ls列出的目录列表中
任何一个具有恰当权限的进程都可以访问FIFO
可以在命令解释程序中执行
mkfifo
命令或者从程序中调用
mkfifo()
函数来创建FIFO删除时使用
rm
命令或unlink()
函数
管道与客户机-服务器模型
- 客户机-服务器模型是进程间交互的一种标准模式。
- 一个被称为客户机(client)的进程,向另一个称为服务器(server)的进程请求服务
- 两种类型的客户机-服务器通信方式:
- 简单-请求(simple-request)方式,客户机以单向传输的方式向服务器发送消息
- 请求-应答(request-reply)方式,客户机发送一个请求,服务器就会发送一个应答
终端控制
很多特殊文件表示的设备都具有与平台相关的特性,这使得标准化工作变得很困难
命令
stty
用来报告或设置终端I/O的特性执行时不带任何参数或者执行时带有选项
-a
或-g
时,命令将与当前终端有关的信息输出到标准输出中去tcgetattr()
函数用来检索出与终端相关的属性,终端由打开的文件描述符fildes
引用原型:
int tcgetattr(int fildes, struct termics *termios_p);
passwordnosigs()
函数使用ctermid()
函数确定的控制终端,而没有使用标准输入控制终端通常类似于
/dev/tty
规范与非规范的输入处理
一种常见的误解是:
- 键盘和显示器是以某种方式连在一起的,因此你键入的所有内容都自动地出现在显示器上
实际上,键盘和显示器是互相独立的设备,它们分别与运行在计算机上的终端设备驱动程序进行通信
通信驱动程序从键盘接收字节,然后根据这些设备的设置指定的方式对其进行缓冲和编辑
处理终端输入的一种常见的方法是每次处理一行输入,这种处理方法被称为规范模式(canonical mode)
行是一个由新行(NL),文件结束(EOF)或行结束(EOL)定界的字节序列
在非规范模式(noncanonical mode)下,输入没有被组装成行
非规范输入处理有两个控制参数:MIN和TIME
- MIN,用来控制read返回之前应该收集的最少的字节数
- TIME,指一个粒度为0.1秒的定时器,这个定时器用于突发传输的超时处理
音频设备
- 音频设备(麦克风,扬声器)是由特殊文件表示的外围设备的一种实例
- 在很多系统中,这些设备的设备表示都是
/dev/audio
路障
- 路障(barrier),是协同操作的进程使用的一种同步结构
- 使用了路障之后,在所有的进程都到达一个特定点之前,进程一直保持阻塞
项目:令牌环(180)
环的形成
- 在程序代码中引用这些文件描述符时,一定要使用
STDIN_FILENO, STDOUT_FILENO
- 文件描述符表的条目,就是指向系统文件表条目的指针
- 例如
- 条目
[4]
中的pipe写
表示:指向系统文件表中pipe的写条目的指针 - 条目
[0]
中的标准输入
表示:指向系统文件中对应于默认标准输入设备的条目的指针(默认的标准输入通常是键盘)
- 条目
匿名环中的领导者选举
分布式算法的规范,将执行算法的实体称为进程(process)或处理器(processor)
这种算法通常用有限状态机的形式来说明底层的处理器模型。
处理器模型,是按照状态转移是如何被驱动的(同步)以及处理器是否被标记来划分的
在同步处理器模型(synchronous processor model)中,处理器按照锁步执行,而状态转移是时钟驱动的
在异步处理器模型(asynchronous processor model)中,状态转移是消息驱动的
- 在通信链路上接收消息会出发处理器状态的改变。处理器可能会向它的邻居发送消息执行某些计算,或者因为有消息输入而暂停。
- 在处理器键任意给定的链接上,消息都按照发送的顺序到达。消息会有一个有限的,但不可预测的传输时延
处理器模型还必须说明单独的处理器是否被标记,或者它们是否是不可辨别的。
在一个匿名系统(anonymous system)中,处理器不具有可以辨别的特性。
总的来说,包含了匿名处理器或进程的系统的算法,比包含有标记的处理器或进程的系统的相应算法更复杂一些
图像过滤
过滤器,是对图像进行的一种变换
根据变换类型的不同,过滤可能会消除噪声,增强细节或者模糊图像的特征。
针对一副由
n x n
字符数组表示的灰度数字图像进行过滤通用的空间过滤器(spatial filter),用一个原始像素及其邻居的函数来替换这幅图像中的每个像素值。
过滤器算法用一个掩码来说明对计算做出贡献的邻居范围。因为函数是掩码中像素的加权和,所以这个特定的掩码,表示了一个线性过滤器(linear filter)
与之相反,非线性过滤器不能写成掩码中像素的线性组合。使用邻近像素的中位数就是非线性滤波器的一个例子
块的计算
- 并行处理中另外一个重要的问题就是问题的粒度,以及如何将这种粒度映射为进程数
信号
- 重点强调了信号处理并发方面的问题
信号的基本概念
信号(signal), 是向进程发送的软件通知,通知进程有事件发生
引发信号的事件发生时,信号就被生成(generate)了。
进程根据信号采取行动时,信号就被传递(deliver)了。
信号的寿命(lifetime)就是,信号的生成和传递之间的时间间隔。
已经生成但还未被传递的信号被称为挂起(pending)的信号,
在信号生成和信号传递之间可能会有相当长的时间。传递信号时,进程必须在处理器上运行
如果在传递信号时,进程执行了信号处理程序(signal handler),那么进程就捕捉(catch)到了这个信号
如果将进程设置为忽略(ignore)某个信号,那么在传递时那个信号就会被丢弃,不会对进程产生影响
信号生成时,所采取的的行动取决于那个信号当前使用的信号处理程序和进程信号掩码(process signal mask)
信号掩码中包含一个当前被阻塞信号(blocked signal)的列表
阻塞一个信号很容易和忽略一个信号混淆起来
- 被阻塞的信号不会像被忽略的信号一样丢弃
- 如果一个挂起信号被阻塞了,那个当进程解决了对那个信号的阻塞时,信号就会被传递出去
产生信号
每个信号都有一个以SIG开头的符号名。
信号的名字都定义在
signal.h
中,任何一个使用了信号的C程序中都要包含这个文件信号的名字表示的是大于0的小整数
在命令解释程序上可以用kill命令产生信号。
历史上,很多信号的默认行为都是将进程终止,kill这个名字就由此而来
在一个程序中调用
kill()
函数会向一个进程发送信号。原型:
int kill(pid_t pid, int sig);
函数将进程ID和一个信号码作为参数。
- 如果参数pid大于0,kill就向那个ID表示的进程发送信号。
- 如果pid为0,kill就像调用程序的进程组成员发送信号
- 如果参数pid为-1, kill就向所有它有权发送信息的进程发送信号
- 如果参数pid的值是其他负数,kill就将信号发送到组ID等于pid的进程组中去
返回值
- 成功,返回0
- 失败,返回-1,并设置errno
进程可以用
raise()
函数向自己发送一个信号int raise(int sig);
函数只有一个参数,即信号码
返回值
- 成功,返回0
- 失败,返回一个非零的错误值,并设置errno
对信号掩码和信号集进行操作
进程可以通过阻塞信号暂时地阻止信号的传递。在传递之前,被阻塞的信号不会影响进程的行为
进程的信号掩码(signal mask)给出了当前被阻塞的信号的集合。信号掩码的类型为
sigset_t
可以用类型为sigset_t的信号集来指定对信号组的操作(例如阻塞或接触阻塞的操作)
信号集由下面的五个函数来操作,每个函数的第一个参数都是一个指向
sigset_t
的指针int sigaddset(sigset_t *set, int signo)
– 负责将signo
加入信号集int sigdelset(sigset_t *set, int signo)
– 将signo
从信号集中删除int sigemptyset(sigset_t *set);
– 对一个sigset_t
类型的信号集进行初始化,使其不包含任何信号int sigfillset(sigset_t *set);
– 对一个sigset_t
类型的信号集进行初始化,使其包含所有的信号int sigismember(const sigset_t *set, int signo);
– 报告signo
是否在sigset_t
中
捕捉与忽略信号 – sigaction
sigaction()
函数,允许调用程序检查或指定与特定信号相关的动作int sigaction(int sig, const struct sigaction *restrict act, struct sigaction *restrict oact);
函数的参数
sig
来指定动作的信号码参数
act
是一个指向struct sigaction
结构的指针,用来说明要采取的动作在基于POSIX的标准中,信号处理程序是一个普通函数,它返回void, 并有一个整型的参数
等待信号 – pause, sigsuspend, sigwait
信号提供了一种不需要忙等(busy waiting)来等待事件的方法。
忙等,是指连续地使用CPU周期来检测事件的发生。通常,程序都通过在循环中测试一个变量的值来进行这种检测。
更有效的方法是,将进程挂起直到所等待的事件发生为止;这样,其他的进程就可以更有效地使用CPU了
POSIX中的
pause(), sigsupend(), sigwait()
提供了三种机制,用来挂起进程,直到信号发生为止pause()
函数,将调用线程挂起,直到传递了一个信号为止,这个信号的动作或者是执行用户定义的处理程序,或者是终止进程如果信号的动作是终止进程,pause就不返回。如果信号被进程捕捉,pause就会在信号处理程序返回之后返回
#include <unistd.h> int pause(void);
pause函数总是返回-1,如果被信号中断,pause就将errno设置为EINTR
要用pause来等待一个待定的信号,就必须确定哪个信号会使用pause返回。这个信息并不是直接可用的,因此信号处理程序必须设置一个标志符,以便程序在pause返回之后对其进行检查
sigsuspend()
,用sigmask指向的那个掩码来设置信号掩码,并将进程挂起,知道进程捕捉到信号为止int sigsuspend(const sigset_t *sigmask);
被捕捉信号的信号处理程序返回时,sigsuspend函数就返回。
sigwait()
函数,一直阻塞直到*sigmask
指定的任何一个信号被挂起为止,然后从挂起信号集中删除那个信号,并接触对它的阻塞当
sigwait()
返回时,从挂起信号集中删除的信号的个数被存储在signo
指向的那个位置中int sigwait(const sigset_t *restrict sigmask, int *restrict signo);
如果成功,返回0。如果失败,返回-1,并设置errno
信号处理原则
- 如果拿不准,就在程序中显式地重启库函数调用,或者使用重启库
- 检查信号处理程序中使用的每个库函数,确保函数在异步信号安全函数的列表中
- 仔细地分析修改外部变量的信号处理程序和访问的那个变量的其他按程序代码之间潜在的交互,阻塞信号以防出现不希望的交互
- 适当的时候保存并回复errno
用异步I/O编程
通常,在执行读操作或写操作时,进程会一直阻塞直到I/O完成为止。
某些注重性能的应用程序宁愿先初始化请求,然后继续执行,这样就允许I/O操作的处理异步(asynchronously)于程序的执行
POSIX:AIO扩展对异步I/O的定义基于四个主要函数。
#include <aio.h>
int aio_read(struct aiocb *aiocbp);
– 允许进程对一个打开的文件描述符上的读操作请求进行排队int aio_write(struct aiocb *aiocbp);
– 对写操作请求进行排队- 它们都只有一个参数 – aiocbp,它是一个指向异步I/O控制块的指针。
aio_read()
从与aiocbp->aio_fildes
相关的文件中将aiocbp->aio_bytes
字节读入一个由aiocbp->aio_buf
指定的缓冲区中。请求被放入队列之后,函数就返回。ssize_t aio_return(struct aiocv *aiocbp);
– 指定I/O操作的状态int aio_error(const struct aiocb *aiocbp);
–
时间和定时器
- 操作系统为进程调度,网络协议超时以及定期更新系统的统计信息等目的使用定时器
- 应用程序通过对系统时间和定时器函数的访问来测量性能或确定事件发生的时间
- 应用程序也可以用定时器来实现协议,控制与用户的交互
POSIX时间
POSIX规定系统应该记录从
Epoch
开始的以秒为单位的时间,每天都被精确地计为86400秒Epoch
,新纪元被定义为协调世界时(也称为UTC,格林尼治标准时间或GMT)的1970年1月1日,00:00(午夜)POSIX基本标准只支持秒级的分辨率,并用类型
time_t
来表示从Epoch开始的时间,time_t
类型通常都用long
类型来实现程序可以通过调用
time()
函数来访问系统时间(从Epoch开始的秒数表示)。如果tloc不为NULL,time函数还会将时间存储在*tloc中#include <time.h> time_t time(time_t *tloc);
- 如果成功,返回从Epoch开始计算的秒数
- 如果失败,返回-1,
difftime函数负责计算两个
time_t
类型的日历时间之间的差值,以简化包含时间的计算difftime函数有两个
time_t
类型的参数,并返回一个double类型的值,其中包含的是第一个参数减去第二个参数得到的差值#include <time.h>
double difftime(time_t time1, time_t time0);
对于需要计算时间差值的计算来说,使用
time_t
类型是很方便的,但是用来打印日期就非常繁琐函数
localtime()
有一个参数,这个参数用来说明从Epoch开始的秒数,并返回一个结构,这个结构中带有根据本地需求调整过的时间成分(例如,日,月和年)struct tm *localtime(const time_t *timer);
asctime()
函数将localtime()
返回的结构转换成字符串char *asctime(const struct tm *timeptr);
ctime()
函数的额功能等同于asctime(localtime(clock))
char *ctime(const time_t *clock);
ctime()
函数用静态存储的方式来保存时间字符串,对ctime的两次调用都将字符串存储在同一个位置,因此在使用第一个值之前,第二次调用可能会将第一个值覆盖
gmtime()
函数的额参数为从Epoch开始的描述,并返回一个结构,这个结构中带有协调时间时表示(UTC)的时间成分struct tm *gmtime(const time_t *timer);
- gmtime函数和localtime函数将时间划分成独立的字段,使得程序可以很容易地输出日期或时间的不同部分
ISO定义结构体
struct tm
中应该包含下列成员:int tm_sec; int tm_min; int tm_hour; int tm_mday; int tm_mon; int tm_year; int tm_wday; int tm_yday; int tm_isdst;
对于程序定时或者控制程序事件来说,用秒作为事件的尺度太粗糙了
POSIX:XSI扩展用
struct timeval
结构,以更精细的尺度来表示时间。struct timeval
结构包含如下成员time_t tv_sec; /* 从Epoch开始的秒数*/
time_t tv_usec; /* 从Epoch开始的微秒数 */
gettimeofday函数用来获取自Epoch以来的,用秒和微妙表示的系统时间。
tp指向的
struct timeval
结构负责接收获取的时间,指针tzp必须为NULL,这个指针是由于历史原因才包含进来的#include <sys/time.h>
int gettimeofday(struct timeval *restrict tp, void *restrict tzp);
- 函数返回0,没有保留其他的值来指示错误
使用实时时钟
时钟(clock),是一个计数器,它的值以固定间隔增加,这个固定间隔被称为时钟分辨率(clock resolution)
POSIX:TMR定时器扩展中包含了各种用
clockid_t
类型的变量表示的时钟struct timespec
结构用来为POSIX:TMR时钟和定时器指定时间,也用来为支持超时的POSIX线程函数指定超时值。struct timespec
结构至少包含下列成员time_t tv_sec; /* 秒 */
long tv_nsec; /* 纳秒 */
POSIX提供了设置时钟时间的函数(clock_settime),获取时钟时间的函数(clock_gettime)和确定时钟分辨率的函数(clock_getres)
#include <time.h>
int clock_getres(clockid_t clock_id, struct timespec *res);
int clock_gettime(clockid_t clock_id, struct timespec *tp);
int clock_settime(clockid_t clock_id, const struct timespec *tp);
每个函数都有两个参数:
- 用来标识特定时钟的
clockid_t
- 一个指向
struct timespec
结构的指针
- 用来标识特定时钟的
返回值
- 成功,返回0
- 失败,返回-1,并设置errno
time函数测量的是实际时间(real time),有时又称为实耗时间或挂钟时间。
在多道程序设计环境中,很多进程共享CPU,因此实际时间并不能精确地测量出执行时间
进程的虚拟时间(virtual time)是进程在运行(running)状态耗费的时间总量。执行时间通常都用虚拟时间而不用挂钟时间来表示
times函数用时间账单信息来填充它的参数buffer指向的
struct tms
结构#include <sys/times.h>
clock_t times(struct tms *buffer);
返回值
- 成功,返回用时钟滴答计数表示的实际耗费的时间,这个时间是从过去的任意一点开始计算的,比如可以从系统或进程的起始时间开始计算
- 失败,返回-1,并设置errno
struct tms
结构至少包含以下成员clock_t tms_utime; /*进程的用户CPU时间*/
clock_t tms_stime; /* 由进程使用的系统CPU时间 */
clock_t tms_cutime;/* 进程及其已终止的子进程的用户CPU时间*/
clock_t tms_cstime; /* 由进程及其已终止的子进程使用的系统CPU时间 */
睡眠函数(247)
自愿地阻塞一段特定时间的进程被称为在睡眠(sleep)。
sleep函数使调用线程挂起,直到经过了特定的秒数,或者调用线程捕捉到信号的时候为止
unsigned sleep(unsigned seconds);
返回值:
- 如果请求的时间已经到了,函数就返回0
- 如果被中断了,sleep函数就返回还没有睡眠的时间值
sleep函数与
SIGALRM
之间有交互作用,所以应该避免在同一个进程中同时使用它们nanosleep函数会使调用线程的执行挂起,直到rqtp指定的时间间隔到期或线程收到一个信号为止
#include <time.h>
int nanosleep(const struct timespec *rqtp, struct timespec *tmtp);
如果函数被信号中断,且rmtp不为NULL,则rmtp指定的位置上包含的就是剩余时间,这样函数可以被重启动
系统时钟CLOCK_REALTIME决定了rqtp的分辨率
返回值
- 成功,返回0
- 失败,返回-1,并设置errno
nanosleep函数试图取代usleep,现在任免都认为usleep已经过时了
- 与usleep相比,nanosleep最主要的有点是,它不会影响包括SIGALRM在内的任何信号的使用
间隔定时器
定时器会在经过一段特定的时间之后产生一个通知
时钟采用增量的方式来记录所经过的时间,定时器与之不同,它通常是减少它的值,并在值为零时产生一个信号
计算机系统通常只有少量的硬件间隔定时器,操作系统通过使用这些硬件定时器可以实现多个软件定时器
分时操作系统也可以用间隔定时器来进行进程调度。
操作系统调度一个进程时,它就为一个被称为调度时间片(scheduling quantum)的时间间隔启动了一个间隔定时器
如果这个定时器到期,而进程还在执行,调度程序就将程序转移到一个就绪队列中去,这样其他进程就可以执行了
在多处理其系统中,每个处理器都需要一个这样的间隔定时器
getitimer函数,获取当前的时间间隔
setitimer函数,启动和终止用户的间隔定时器
#include <sys/time.h>
int getitimer(int which, struct itimerval *value);
int setitimer(int which, const struct itimerval *restrict value, struct itimerval *restrict ovalue);
参数which用来指定定时器(即ITIMER_REAL,ITIMER_VIRTUAL, ITIMER_PROF)
返回值:
- 函数成功,返回0
- 失败,返回-1,并设置errno
实时信号
在基本的POSIX标准中,信号处理程序是一个带有单个整型参数的函数,这个整数代表了所产生信号的信号码
POSIX:XSI扩展和POSIX:RTS实时信号扩展都对信号处理能力进行了扩展,包含了信号排队和向信号处理程序传递信息的能力
标准对sigaction结构进行了扩展,以允许信号处理程序使用额外的参数。
如果定义了_POSIX_REALTIME_SIGNALS,实现就可以支持实时信号
sigqueue函数向ID为pid的进程发送带有值value的信号signo
如果signo为零,就会执行错误检测,但是不会发送信号
#include <signal.h>
int sigqueue(pid_t pid, int signo, const union sigval value);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
POSIX:TMR间隔定时器
POSIX:XSI扩展的间隔定时器功能分配给每个进程少量的,固定数目的定时器
POSIX:TMR扩展采用了一种替换方法,在这种方法中只有少量的时钟,一个进程可以为每个时钟创建很多独立的定时器
POSIX:TMR定时器是基于
struct itimerspec
结构的,这个结构包含下列成员struct timespec it_interval; /* 定时器周期 */
struct timespec it_value; /* 定时器到期值 */
对POSIX:XSI定时器来说,
it_interval
是定时器到期后,用来重置定时器的时间成员
it_value
装载的是定时器到期之前剩余的时间进程可以通过调用
timer_create
创建特定的定时器。定时器是每个进程自己的,不是在fork时继承的
#include <signal.h>
#include <time.h>
int timer_create(clockid_t clock_id, struct sigevent *restrict evp, timer_t *restrict timerid);
参数:
timer_create()
的参数clock_id
说明定时器是基于哪个时钟的,*timerid
装载的是被创建的定时器的ID- 参数evp指定了定时器到期时要产生的异步通知,如果为NULL,那么定时器就会产生默认的信号
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
代码段
timer_t timerid;
if(timer_create(CLOCK_REALTIME, NULL, &timerid) == -1) {perror("Failed to create a new timer)};
timer_delete()
函数删除了ID为timerid的POSIX:TMR定时器#include <time.h>
int timer_delete(timer_t timerid);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
操纵每个进程一个的POSIX:TMR定时器
- 函数timer_settime负责启动或停止timer_create创建的定时器。参数flags说明定时器使用的是相对时间还是绝对时间
- 相对时间与POSIX:XSI定时器使用的策略类似,
- 绝对时间的精确度更高,并可以对定时器漂移进行控制
- 函数timer_settime用value指向的值来设置timerid指定的定时器。如果ovalue不为NULL,timer_settime就将定时器以前的值放在ovalue指定的位置上。
- 函数timer_settime负责启动或停止timer_create创建的定时器。参数flags说明定时器使用的是相对时间还是绝对时间
有可能一个定时器到期了,而同一个定时器上一次到期时产生的信号还处于挂起状态,在这种情况下,其中的一个信号可能会丢失,这就称作定时器超限(timer overrun)
程序可以通过调用
imer_getoverrun()
来确定一个特定的定时器出现这种超限的次数- 定时器超限,只会发生在同一个定时器产生的信号上
- 由多个定时器,甚至是那些使用相同的时钟和信号的定时器,所产生的信号都会排队而不会丢失
定时器漂移,超限和绝对时间
一个与POSIX:TMR定时器和POSIX:XSI定时器有关的问题就是,根据相对时间来设置这些定时器的方式
假设,设置了一个间隔2秒的周期性中断,当定时器到期后,系统自动地用另一个2秒的间隔来重启定时器。
假设从定时器到期,到定时器被重新设置之间的等待时间为5纳秒,那么定时器的实际周期为2.000005秒,在1000此中断之后,定时器会偏离5ms
这种不准确型就被称为定时器漂移(timer drift)
处理漂移问题的一种方法是记录定时器实际上什么时候应该到期,并调整每次设置定时器的值。
这种方法使用绝对时间(absolute time),而不是相对时间(relative time)来设置定时器
破解命令解释程序
命令解释程序(shell),是一个用来对命令行进行解释的程序
换句话来说,命令解释程序从标准输入读入命令行,并执行对应于输入行的命令
在最简单的情况下,命令解释程序读入一条命令并创建一个子进程来执行命令。然后父进程要在读入另一条命令之前,等待这个子进程执行完毕。
实际的命令解释程序,要负责处理进程流水线和重定向,以及前台进程组,后台进程组和信号
重定向
- POSIX通过文件描述符用独立于设备的方式来处理I/O。
- 通过open或pipe这样的调用获得一个打开的文件描述符之后,程序就可以用从调用中返回的句柄来执行read或write了
- 重定向允许程序将一个已经打开了的句柄重新分配,去标识另一个文件
进程组,会话和控制终端
进程组(process group),是为信号传递这样的目的建立的进程集合。
每个进程都有一个进程组ID(process group ID)来表示它所属的进程组
kill命令和kill函数都将负的进程ID值作为进程组ID来处理,并向相应进程组的每个成员发送一个信号
进程组组长(process group leader),是一个进程,它的进程ID值与进程组ID值相同。
只要进程组里还有进程,进程组就一直存在。如果组长死了或者加入了另一个组,进程组可能就没有组长了
进程可以用setpgid来改变它的进程组。
setpdig函数将进程pid的进程组ID设置为进程组ID gpid。如果pid为0,它就使用调用进程的进程ID。如果pgid为0,pid指定的进程就成为一个组长
#include <unistd.h>
int setpgid(pid_t pid, pid_t gpid);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
getpgrp函数返回调用进程的进程组ID
#include <unistd.h>
pid_t getpgrp(void);
为了实现信号的透明传递,POSIX使用了会话和控制终端
会话(session),是为作业控制建立的一个进程组的集合。
会话的创建者就被称为会话组长(session leader)
用会话组长的进程ID来标识会话。每个进程都属于一个会话,会话是它们从父进程那里继承来的
每个会话都应该有一个与之相关的控制终端(controlling terminal)
命令解释程序用它的会话的控制终端来与用户交互
一个特定的控制终端只与一个会话有关,一个会话中可能有多个进程组,但在任一给定的时候,只有一个进程组可以从控制终端接收输入并向控制终端发送输出。
这个特定的进程组被称为前台进程控制组(foreground process group)或前台作业(foreground job)
会话中其余的进程组被称为后台进程组(background process group)或后台作业(background job)
作业控制的主要目的是改变在前台的进程组
用ctermid函数来获取控制终端的名字
#include <stdio.h>
char *ctermid(char *s);
函数返回一个指向字符串的指针,这个字符串对应于当前进程的控制终端的路径名
如果s是一个NULL指针,这个字符串可能位于静态生成的区域中,
如果s不为NULl,它应该指向一个至少有L_Ctermid字节的字符数组
ctermid函数将一个表示控制终端的字符串拷贝到那个数组中去
如果失败,返回空字符串
进程可以通过调用setsid来创建一个以它自己为组长的新会话
#include <unistd.h>
pid_t setsid(void);
返回值:
- 成功,返回新的进程组ID值
- 失败,返回-1, 并设置errno
进程可以通过调用getsid来发现会话ID
函数getsid将一个进程组ID–pid,作为参数,并返回pid指定的那个进程的会话组长的进程组ID
#include <unistd.h>
pid_t getsid(pid_t pid);
返回值:
- 成功,返回一个进程组ID
- 失败,返回-1,并设置errno
作业控制
如果命令解释器程序允许用户将前台进程组移到后台去,并允许用户将进程组从后台移到前台,那么这个命令解释程序就是有作业控制(job control)功能的
作业控制包括对控制终端前台进程组进行修改
tcgetpgrp函数,用来返回一个特定控制终端的前台进程组的进程组ID
要获得控制终端的打开的文件描述符,就要打开ctermid函数中获得的路径名
#include <unistd.h>
pid_t tcgetpgrp(int fildes);
返回值
- 成功,返回与终端相关的前台进程组的进程组ID
- 失败,返回-1,并设置errno
tcsetpgrp函数将与fildes相关的控制终端的前台进程组设置为pgid_id
#include <unistd.h>
int tcsetpgrp(int fildes, pid_t pgid_id);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
并发
- 实现并行的一种方法是多个进程通过共享内存或消息传递来进行协作和同步,另一种方法是在单个地址空间中使用多个执行线程
线程管理
线程包中通常包含了用于线程创建和线程销毁,调度,强制互斥和条件等待的函数
典型的线程包中还包括一个运行系统来对线程进行透明的管理,也就是说,用户是不知道运行系统的存在的
线程被创建时,运行系统分配数据结构来状态线程ID,栈和程序计数器值。
线程的内部数据结构中可能还包括调度和使用信息。
一个进程的各个线程共享那个进行的整个地址空间。它们可以修改全局变量,访问打开的文件描述符,并用其他的方式互相配合或互相干扰
因为所有的线程函数都以pthread开始,所以有时POSIX线程被称为pthreads
POSIX线程管理函数
pthread_cancel
– 终止另一个线程pthread_create
– 创建一个线程int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, void*(*start_routine)(void *), void *restrict arg);
- pthread_create函数创建了一个线程。与有些线程工具,例如Java编程语言提供的那些线程工具不同,POSIX的pthread_create会自动使线程成为可运行的,而不需要一个单独的启动操作
- 参数
- thread,指向新创建的线程ID
- attr, 表示一个封装了线程的各种属性的属性对象。如果为NULL,新线程就具有默认的属性
- start_routine,是线程开始执行的时候调用的函数的名字。
- start_routine有一个由arg指定的参数,这个参数是一个指向void的指针
- start_routine返回一个指向void的指针,这个返回值被pthread_join当做退出状态来处理
- 返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
pthread_detach
– 设置线程以释放资源pthread_equal
– 测试两个线程ID是否相等pthread_t pthread_equal(pthread_t t1, pthread_t t2);
- 如果t1等于t2,pthread_equal返回一个非零值;如果不相等,返回0
pthread_exit
– 退出线程,而不退出进程pthread_kill
– 向线程发送一个信号pthread_join
– 等待一个线程pthread_self
– 找出自己的线程IDpthread_t pthread_self(void);
返回值:
- 成功,大多数线程函数都返回0
- 失败,大多数线程函数都会返回非零的错误码,它们不设置errno,因此调用程序不能用perror来报告错误
分离和连接
除非是一个分离线程,否则在线程退出时,它是不会释放它的资源的
pthread_detach函数将线程分离,它设置线程的内部选项来说明线程退出后,线程的存储空间可以被重新收回
分离线程退出时不会报告它们的状态。没有分离的线程是可接合的,而且在另一个线程为它们调用pthread_join或者整个进程退出之前,这些线程不会释放它们所有的资源
pthread_detach函数有一个参数thread,这个参数是要分离的线程的线程ID
int pthread_detach(pthread_t thread);
返回值:
- 成功,返回0,
- 失败,返回一个非零的错误码
在另一个线程用终止线程的ID值作为第一个参数调用pthread_join之前,未分离线程的资源是不会被释放的
int pthread_join(pthread_t thread, void **value_ptr);
pthread_join函数将调用线程挂起,直到第一个参数指定的目标线程终止为止
参数value_ptr为指向返回值的指针提供了一个位置,这个返回值是由目标线程传递给pthread_exit或return的
如果value_ptr为NULL,调用程序就不会对目标线程的返回状态进行检索了
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
如果线程执行
pthread_join(pthread_self());
,会发生什么情况- 假设线程是可接合的(不是已分离的),这条语句就会造成死锁
- 有些实现可以检测到死锁,并迫使pthread_join带着错误EDEADLK返回
- 但是,POSIX:THR扩展并不要求进行这种检测
退出和取消
进程的终止可以通过直接调用exit,执行main中的return,或者通过进程的某个其他线程调用exit来实现
在任何一种情况下,所有的线程都会终止
如果主线程在创建了其他线程之后没有工作可做,它就应该阻塞到所有线程都结束为止,或者应该调用
pthread_exit(NULL)
调用exit会使整个进程终止
调用pthread_exit只会使调用线程终止
void pthread_exit(void *value_ptr);
在顶层执行return的线程隐式地调用了pthread_exit,调用时将返回值(一个指针)当做pthread_exit的参数使用。
如果进程的最后一个线程调用了pthread_exit,进程会带着状态返回值0退出
对一个成功的pthread_join来说,value_ptr的值是可用的。
但是,pthread_exit中的value_ptr必须指向线程退出后仍然存在的数据,因此线程不应该为value_ptr使用指向自动局部数据的指针
线程可以通过取消机制,迫使其他线程返回。线程可以调用pthread_cancel来请求取消另一个线程。
结果由目标线程的类型和取消状态决定
int pthread_cancel(pthread_t thread);
参数:
- thread, 要取消的目标线程的线程ID
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
线程收到一个取消请求时会发生什么情况取决于它的状态和类型。
如果线程处于PTHREAD_CANCEL_ENABLE状态,它就接收取消请求
另一方面,如果线程处于PTHREAD_CANCEL_DISABLE状态,取消请求就会被保持在挂起状态。
默认情况下,线程处于PTHREAD_CANCEL_ENABLE状态
pthread_setcancelstate函数用来改变调用线程的取消状态
int pthread_setcancelstat(int state, int *oldstate);
参数
- state,说明要设置的新状态
- oldstate, 指向一个整数的指针,这个整数中装载了以前的状态
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
作为一个通用的原则,改变了其取消状态或类型的函数应该在返回之前回复它们的值
pthread_setcanceltype函数,根据它的type参数指定的值来修改线程的取消类型
线程安全
线程中隐藏的一个问题是它们可能会调用非线程安全的库函数,这样可能会产生错误的结果。
如果多个线程能够同时执行函数的多个活动请求而不会相互干扰,那么这个函数就是线程安全的(thread-safe)
在传统的UNIX实现中,errno是一个全局外部变量,当系统函数产生一个错误时,就会设置errno。
对多线程来说,这种实现方式是无法工作的,在大多数线程实现中,errno是一个用来返回线程的特定信息的宏
本质上来说,每个线程都有一份私有的errno拷贝。
主线程不能直接访问一个接合线程的errno,因此如果需要的话,必须通过pthread_join的最后一个参数来返回这些信息
用户线程和内核线程
用户级线程(user-level thread)和内核级线程(kernel-level thread)是两种传统的线程控制模式
用户级线程,通常都运行在一个现存的操作系统之上。
这些线程对内核来说是不可见的,它们之间还会竞争分配给它们的封装进程的资源。
线程由一个线程运行系统来调度,这个系统是进程代码的一部分
带有用户级线程的程序通常会连接到一个特殊的库上去,这个库中的每个库函数都用外套(jacket)包装起来
POSIX引入了一个线程调度竞争范围(thread-scheduling contention scope)的概念,这个概念赋予了程序员一些控制权,使他们可以控制怎样将内核实体映射为线程
线程的属性
POSIX将栈的大小和调度策略这样的特征封装到一个pthread_attr_t类型的对象中去,用面向对象的方式表示和设置特征
属性对象只是在线程创建的时候会对线程产生映像。可以先创建一个属性对象,然后再将栈的大小和调度策略这样的特征与属性对象关联起来
可以通过向pthread_create传递相同的线程属性对象来创建多个具有相同特征的线程
pthread_attr_init用默认值对一个线程属性对象进行初始化
pthread_attr_destroy函数将属性对象的值设为无效的
int pthread_attr_destroy(pthread_attr *attr);
int pthread_attr_init(pthread_attr *attr);
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
pthread_attr_getdetachstate函数用来查看一个属性对象的状态
pthread_attr_setdetachstate函数用来设置一个属性对象的状态
线程栈
线程有一个栈,用户可以设置栈的位置和大小,如果必须将线程栈放在一个特定的内存区中,这就是一个有用的特征
要为线程定义栈的布局和大小,就必须先用特定的栈属性来创建一个属性对象,然后用这个属性对象来调用pthread_create
pthread_attr_getstack函数用来查看栈的参数
pthread_attr_setstack函数用来设置一个属性对象的栈参数
int pthread_attr_getstack(const pthread_attr_t *restrict attr, void **restrict stackaddr, size_t *restrict stacksize);
int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
参数
- 每个函数的参数attr都是一个指向属性对象的指针
- pthread_attr_setstack函数将栈的地址和栈的大小作为额外的参数
- pthread_attr_getstack函数则将指向这些条目的指针当做参数
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
如果用户还没有设置stackaddr,POSIX还提供了检查栈溢出或者为栈溢出设置警戒的函数
pthread_attr_getguardsize函数用来查看警戒参数
pthread_attr_setguardsize函数在一个属性对象中设置了用来控制栈溢出的警戒参数
int pthread_attr_setguardsize(const pthread_attr_t *restrict attr, size_t *restrict guardsize);
int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
线程调度
对象的竞争范围(contention space)控制了线程是在进程内部还是在系统级竞争调度资源
pthread_attr_getscope用来查看竞争范围
pthread_attr_setscope用来设置一个属性对象的竞争范围
int pthread_attr_getscope(const pthread_attr_t *restrict attr, int *restrict conttentionspace);
int pthread_attr_setscope(pthread_attr_t *attr, int contentionscope);
参数:
- attr是一个指向属性对象的指针
- contentionscope对应于要为pthread_attr_setscope设置的值,以及一个指向要从pthread_attr_getscope获得的值的指针
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
POSIX允许线程用不同的方式继承调度策略
pthread_attr_getinheritsched函数负责查看调度继承策略
pthread_attr_setinheritsched函数负责为一个属性对象设置调度继承策略
int pthread_attr_getinheritsched(const pthread_attr_t *restrict attr, int *restrict inheritsched);
int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);
返回值:
- 成功,返回0
- 失败,返回一个非零的错误码
线程同步
- POSIX支持用于短期锁定的互斥锁以及可以等待无期限事件的条件变量
互斥锁
互斥量,是一种特殊的变量,它可以处于锁定(locked)状态,也可以处于解锁(unlocked)状态
如果互斥量是锁定的,就有一个特定的线程持有(hold)或拥有(own)这个互斥量
如果没有线程持有这个互斥量,就说这个互斥量处于解锁(unlocked),空闲(free)或可用(available)的状态
互斥量还有一个等待持有该互斥量的线程队列。
互斥量的等待队列中的线程获得互斥量的顺序由线程调度策略确定,但POSIX没有要求实现任何特定的策略
互斥量(mutex)或互斥锁(mutex lock),是最简单也是最有效的线程同步机制
程序用互斥锁来保护临界区,以获得对资源的排他性访问权
互斥量只能被段时间地持有。
互斥函数不是线程取消点,也不能被信号中断
除非进程终止了,(从信号处理程序中)用pthread_exit终止了线程,或者异步取消了线程(通常不用这种方法),否则,等待互斥量的线程不能被逻辑地中断
出现等待输入这样的持续时间不确定的情况下,用条件变量来进行同步
POSIX使用 pthread_mutex_t 类型的变量来表示互斥锁
程序在用 pthread_mutex_t 变量进行同步之前,通常必须对其进行初始化
- 对静态分配的 pthread_mutex_t 变量来说,只要将PTHREAD_MUTEX_INITIALIZER赋给变量就可以了
- 对动态分配或没有默认互斥属性的互斥变量来说,要调用pthread_mutex_init来执行初始化工作
pthread_mutex_init的参数mutex是一个指向要初始化的互斥量的指针
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数
- attr, 互斥属性对象,传入NULL,使用默认属性
返回值:
- 成功,返回0
- 失败,返回非零的错误码
pthread_mutex_destroy函数销毁了它的参数所引用的互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数
- mutex,是一个指向要销毁的互斥量指针
返回值:
- 成功,返回0
- 失败,返回非零的错误码
pthread_mutex_lock函数会一直阻塞到互斥量可用为止
pthread_mutex_trylock函数,通常会立即返回
pthread_mutex_unlock函数用来释放指定的互斥量
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
这三个函数都只有一个参数mutex,这个参数是一个指向互斥量的指针
返回值:
- 成功,返回0
- 失败,返回非零的错误码
因为互斥锁必须能被所有需要同步的线程访问,所以,它们通常会以全局变量的形式出现(内部或外部链接)
线程化程序中大多数共享的数据结构都必须由同步机制保护,以确保能得到正确的结果
最多一次和至少一次的执行
- 单次初始化的概念非常重要,POSIX甚至还提供了一个pthread_once函数来确保这个语义的实现
int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));
pthread_once_t once_control = PTHREAD_ONCE_INIT;
- 必须用PTHREAD_ONCE_INIT对参数
once_control
进行静态初始化 - 返回值:
- 成功,返回0
- 失败,返回非零的错误码
条件变量
考虑一个使进程一直等待,直到某个任意的条件被满足了为止的问题。
具体来说,假设有两个变量x和y被多个线程共享。希望一个线程一直等到x和y相等为止。
典型的不正确的忙等解决方法是:
while (x != y);
断言x == y为真所用的正确的非忙等策略
- 锁定互斥量
- 测试条件x == y
- 如果为真,解除对互斥量的锁定,并退出循环
- 如果为假,将线程挂起,并解除对互斥量的锁定
应用程序通过使用pthread_mutex_lock和pthread_mutex_unlock这样定义的很明确的系统库函数来操纵互斥队列。
这些函数还不足以实现(用一种简单的方式)这里要求的队列操作。
需要一种新的数据类型,一种与等待x == y这样的任意条件为真的进程队列相关的数据类型。这样的数据类型被称为条件变量(condition variable)
函数pthread_cond_wait将一个条件变量和一个互斥量作为参数,它原子地挂起调用线程并解除对互斥量的锁定
可以认为它将线程放入了一个线程队列,队列中的线程都在等待条件发生变化的通知
线程收到通知时,函数会带着重新获得的互斥量返回
在继续执行之前,线程必须再次对条件进行测试
如何用POSIX条件变量v和互斥量m来等待条件x == y
pthread_mutex_lock(&m);
while (x != y)
pthread_cond_wait(&v, &m);
pthread_mutex_unlock(&m);
函数pthread_cond_wait只能由拥有互斥量的线程调用,当函数返回时,线程就再次拥有了互斥量
条件变量的使用和sigsuspend的使用
两个概念是相似的
阻塞信号并对条件进行测试。因为在信号被阻塞的时候,信号处理程序不能访问全局变量sigreceived,所以阻塞信号与锁定互斥是类似的
当sigsuspend返回时,信号再次被阻塞。
线程用条件变量锁定互斥量来保护它的临界区并对条件进行测试。
pthread_cond_wait原子地释放了互斥量并将进行挂起。当pthread_cond_wait返回时,线程就再次拥有了互斥量
创建和销毁条件变量
POSIX用pthread_cond_t类型的变量来表示条件变量
程序必须在使用该变量之前对其进行初始化
对那些静态分配的,带有默认属性的pthread_cond_t变量来说,简单地将PTHREAD_COND_INITIALIZE赋给变量就可以完成初始化
对那些动态分配的或不具有默认属性的变量来说,就要调用pthread_cond_init来执行初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZE;
返回值:
- 成功,返回0
- 失败,返回非零的错误码
函数pthread_cond_destroy销毁了它的参数cond引用的条件变量
int pthread_cond_destroy(pthread_cond_t *cond);
返回值:
- 成功,返回0
- 失败,返回非零的错误码
等待并通知条件变量
条件变量是与断言或条件的测试一同调用的,条件变量的名字就是从这个事实引申出来的
通常,线程会对一个断言进行测试,如果测试失败,就调用pthread_cond_wait
函数pthread_cond_timewait可以用来等待一段有限的时间
int pthread_cond_timewait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
这些函数的第一个参数是cond,这是一个指向条件变量的指针
第二个参数是mutex,这是一个指向互斥连的遏制真
- 线程在调用之前应该拥有这个互斥量
- 当线程被放置在条件变量等待队列中时,等待操作会使线程释放这个互斥量
pthread_cond_timewait函数的第三个参数是一个指向返回时间的指针,这个值表示的是绝对时间,而不是时间间隔
返回值:
- 成功,返回0
- 失败,返回非零的错误码
当另一个线程修改了可能会使断言成真的变量时,它应该唤醒一个或多个在等待断言成真的线程
pthread_cond_signal函数至少解除了对一个阻塞在cond指向的条件变量上的线程的阻塞
pthread_cond_broadcast函数解除了所有阻塞在cond指向的条件变量上的线程的阻塞
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
返回值:
- 成功,返回0
- 失败,返回非零的错误码
条件变量规则
条件变量没有被链接到特定的断言上去,pthread_cond_wait可能会因为假唤醒而返回
使用条件变量时要遵守的规则
- 在测试断言之前获得互斥量
- 因为返回可能是由某些不相关的事件或无法使断言成真的pthread_cond_signal引起的,所以要在从pthread_cond_wait返回值后重新对断言进行测试
- 在修改断言中出现的任一变量之前,要获得互斥量
- 仅仅在较短的时间段中持有互斥量 – 通常是在测试断言或者修改共享变量的时候
- 显示地(用pthread_mutex_unlock)或隐式地(用pthread_cond_wait)释放互斥量
信号处理与线程
进程中所有线程都共享进程的信号处理程序,但每个线程都有它自己的信号掩码
由于线程的操作可以异步于信号,所以线程与信号的交互会比较复杂
三种类型的信号及其相应的传递方法
- 异步 – 传递给某些解除了对该信号的阻塞的线程
- 同步 – 传递给引发(该信号)的线程
- 定向的 – 传递给标识了的线程(pthread_kill)
SIGFPE(浮点异常)这样的信号就是同步于引发它们的线程(也就是说,它们通常在线程执行的相同位置上产生)
pthread_kill函数要求产生信号码为sig的信号,并将其传送到thread指定的线程中去
int pthread_kill(pthread_t thread, int sig);
返回值:
- 成功,返回0
- 失败,返回非零的错误码
使线程将它自己和整个进程都杀死
if (pthread_kill(pthread_self(), SIGKILL))
fprintf(stderr, "Failed to commit suicide \n");
一种常见的概念混淆是假定pthread_kill总是会使进程终止的,但实际上并不是这样的,pthread_kill仅仅为线程产生一个信号
为线程屏蔽信号。虽然信号处理程序是进程范围的,但是每个线程都有它自己的信号掩码
线程可以用pthread_sigmask函数来检查或设置它的信号掩码
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
参数how和set指出了修改信号掩码的方式
- how的值SIG_SETMASK会使线程的信号掩码被set取代
- 也就是说,现在线程阻塞set中所有的信号,但不阻塞任何其他信号
- how的值SIG_BLOCK使线程阻塞set中的其他信号(添加到线程当前的信号掩码中)
- how的值SIG_UNBLOCK从线程当前的信号掩码中将set中当前被阻塞的信号删除(不再阻塞)
如果参数oset不为NULL,函数就将*oset设置为线程的前一个信号掩码
返回值:
- 成功,返回0
- 失败,返回非零的错误码
为信号处理指定专用线程
信号处理程序是进程范围的,与在单线程的进程中一样,可以用sigaction调用来安装它们
在线程化程序中,进程范围的信号处理程序和线程特定的信号掩码之间的区别是很重要的
在多线程的进程中进行信号处理的一种推荐策略是:为信号处理使用特定的线程
主线程在创建线程之前阻塞了所有的信号。信号掩码是从创建线程中继承的。
这样,所有的线程都将信号阻塞了。然后,专门用来处理信号的线程对那个信号执行sigwait
或者,线程可以用pthread_sigmask来接触对信号的阻塞
读者和写者
读-写者问题指的是这样的一种情况:
- 在这种情况下,允许对资源进行两种类型的访问(读和写)
- 一种类型的访问必须确保是排他的(比如,写操作),但是另一种类型的访问可以是共享的(比如,读操作)
处理读-写者同步的两种常见的策略被称为强读者同步(strong reader synchronization)和强写者同步(strong writer synchronization)
- 在强读者同步中,总是给读者以优先权,只要写者当前没有进行写操作,读者就可以获得访问权
- 在强写者同步中,通常将优先权交给写者,二将读者延迟到所有等待或活动的写者都完成了为止
POSIX提供了读-写锁:如果写者没有持有锁,就允许多个读者获得这个锁
POSIX声明,当前写者阻塞在锁上时,就由实现来决定是否允许读者获取锁
POSIX读-写锁由pthread_rwlock_t类型的变量表示。
程序在用pthread_rwlock_t变量进行同步之前,必须调用pthread_rwlock_init来初始化这个变量
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
参数rwlock是一个指向读-写锁的指针
将NULL传递给pthread_rwlockattr_t,以便用默认属性来初始化读-写锁。否则,就要使用与线程属性对象类似的方法,县创建,然后再初始化读-写锁属性对象
返回值:
- 成功,返回0
- 失败,返回非零的错误码
pthread_rwlock_destroy函数销毁了它的参数引用的读-写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
参数rwlock是一个指向读-写锁的指针
返回值:
- 成功,返回0
- 失败,返回非零的错误码
pthread_rwlock_rdlock和pthread_rwlock_tryrdlock函数允许线程为读操作获取一个读-写锁
pthread_rwlock_wrlock和pthread_rwlock_trywrlock函数允许线程为写操作获取一个读-写锁
pthread_rwlock_ldlock和pthread_rwlock_wrlock函数一直保持阻塞,到有锁可用为止
pthread_rwlock_tryldlock和pthread_rwlock_trywrlock函数则会立即返回
pthread_rwlock_unlock函数会将锁释放掉
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
这些函数要求将一个指向锁的指针作为参数传递
返回值:
- 成功,返回0
- 失败,返回非零的错误码
strerror_r的实现
通常,主线程是唯一一个打印错误消息的线程
strerror为非线程安全的函数
perror_r和sterror_r函数既是线程安全的,又是异步信号安全的
临界区和信号量
- 管理共享资源的程序必须以互斥的方式来执行被称为临界区的代码段
处理临界区
共享设备,被称为排他性资源(exclusive resources),因为它们一次只能由一个进程访问
进程必须以互斥(mutually exclusive)的方式来执行访问这些共享资源的代码
临界区(critical section),是必须以互斥的方式执行的代码段,也就是说,在临界区的范围内,只能有一个活动的执行线程
临界区问题(critical section problem),是指安全,公平和对称的方式来执行临界区代码的问题
可以将带有同步临界区的代码组织成不同的部分
- 入口区(entry section),包含了请求对共享变量或其他资源进行修改的代码
- 临界区(critical section),包括访问共享资源或执行不可重入代码的代码
- 退出区(exit section),提供的对访问权的显示释放是必须的
- 剩余区(remainder section),释放了访问权之后,线程可以执行的其他代码
好的临界区问题解决方案要求公平和排他性访问(exclusive access)。
- 试图进入临界区的执行线程不应该被无限期地推迟
- 线程也应该有进展
- 如果当前没有线程在临界区,就应该允许一个等待线程进入
信号量
信号量是一个整型变量,它带有两个原子操作wait和signal
- wait还可以被称为down, P或lock
- signal还可以称为up, V, unlock或post
在POSIX:SEM的术语中,wait和signal操作分别被称为信号量锁定(semaphore lock)和信号量解锁(semaphore unlock)
我们可以把信号量想成一个整数值和一个等待signal操作的进程列表
wait和signal操作必须是原子的。
原子操作(atomic operation)是这样一种操作,一旦将其启动了,就要以一种逻辑上不可分割的方式来完成(也就是说,不会与任何其他相关的指令产生交错)
POSIX:SEM无名信号量
POSIX:SEM信号量是一个sem_t类型的变量,有相关的原子操作来对它的值进行初始化,增量和减量操作
POSIX:SEM信号量扩展定义了两种类型的信号量:命名信号量和无名信号量
如果一个实现在unistd.h中定义了_POSIX_SEMAPHORES,那么这个实现就支持POSIX:SEM信号量
无名信号量和命名信号量之间的区别类似于普通管道和命名管道(FIFO)之间的区别
#include <semaphore.h>
sem_t sem;
必须在使用POSIX:SEM信号量之前对其进行初始化
sem_init函数将sem引用的无名信号量初始化为value
int sem_init(sem_t *sem, int pshared, unsigned value);
参数value不能为负
pshared等于0,说明信号量只能由初始化这个信号量的进程中的线程使用
如果pshared非0,任何可以访问sem的进程就都可以使用这个信号量
返回值:
- 成功,sem_init就将sem初始化
- (没有定义返回值)
sem_destroy函数销毁了一个参数sem引用的,已经初始化了的无名信号量
int sem_destroy(sem_t *sem);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
sem_post对信号量的值进行增量操作
int sem_post(sem_t *sem);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
sem_wait函数实现了经典的信号量wait操作
int sem_wait(sem_t *sem);
如果信号量的值为0,调用进程就一直阻塞直到一个相应的sem_post调用解除了对它的阻塞为止,或者直到它被信号中断为止
sem_trywait与sem_wait类似,只是在试图对一个为零的信号量进行减量操作时,它不阻塞,而是返回-1并将errno置为EAGAIN
int sem_trywait(sem_t *sem);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
sem_getvalue函数允许用户检测一个命名信号量或者无名信号量的值
int sem_getvalue(sem_t *restrict sem, int *strict sval);
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
POSIX:SEM命名信号量
可以用POSIX:SEM命名信号量来同步那些不共享内存的进程
命名信号量和文件一样,有一个名字,一个用户ID,一个组ID的权限
信号量的名字是一个遵守路径名构造规则的字符串
sem_open函数建立了命名信号量和sem_t值之间的连接
sem_t *sem_open(const char *name, int oflag, ...);
参数name是一个用名字来标识信号量的字符串,这个名字可以对应于文件系统中实际的对象,也可以不对应
参数oflag用来确定是创建信号量,还是仅仅由函数对其进行访问
返回值:
- 成功,返回信号量的地址
- 失败,返回SEM_FAILED,并设置errno
sem_close函数关闭命名信号量,但是这样做并不能将信号量从系统中删除
int sem_close(sem_t *sem);
参数sem,用来指定要关闭的信号量
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
sem_unlink函数与文件或FIFO的unlink函数类似,在所有的进程关闭了命名信号量之后将命名信号量从系统中删除
当进程显式地调用sem_close, _exit, exit, exec或执行从main的返回时,就会出现关闭操作
int sem_unlink(const char *name);
参数name,指向要删除的信号量的指针
返回值:
- 成功,返回0
- 失败,返回-1,并设置errno
POSIX IPC
- 共享内存,消息队列和信号量集等经典的UNIX进程间通信(IPC)机制都在POSIX:XSI扩展中进行了标准化
- 这些机制允许不相关的进程通过一种合理有效的途径来交换信息,这些机制用键(key)来标识,创建或访问相应的额实体
POSIX:XSI进程间通信
POSIX进程间通信(interprocess communication, IPC),是POSIX:XSI扩展的一部分,起源于UNIX System V进程间通信
IPC中包含消息队列,信号量集和共享内存,为同一个系统中的进程提供了共享信息的机制
- 消息队列
- msgctl – 控制
- msgget – 创建或访问
- msgrcv – 接收消息
- msgsnd – 发送消息
- 信号量
- semctl – 控制
- semget – 创建或访问
- semop – 执行操作(等待或发送)
- 共享内存
- shmat – 将内存附加到进程中去
- shmctl – 控制
- shmdt – 将内存从进程中分离开
- shmget – 创建并初始化或访问
- 消息队列
POSIX:XSI用一个唯一的整数来标识每个IPC对象,这个整数大于或等于零,从对象的获取函数中返回这个整数的方式与open函数返回表示文件描述符的整数的方式类似
创建或访问一个IPC对象时,必须指定一个键来说明要创建或访问的特定对象。
有三种方式来选择一个键
- 由系统来选择一个键(IPC_PRIVATE)
- 直接选一个键
- 通过调用ftok请求系统从指定的路径中生成一个键
ftok函数允许独立的进程根据一个已知的路径名导出相同的键。
#include <sys/ipc.h>
key_t ftok(const char *path, int id);
对应于路径名的文件必须存在,并且必须能够被那些想访问IPC对象的进程访问
path和id的组合唯一地标识了IPC对象。
参数id允许几个相同类型的IPC对象从一个路径名中生成键值
返回值:
- 成功,返回一个键
- 失败,返回-1,并设置errno
从命令解释程序中访问POSIX:XSI IPC资源
- 命令解释程序和实用程序的POSIX:XSI扩展定义了检查和删除IPC资源的命令解释程序命令,这时POSIX:SEM信号量所没有的一项很方便的特性
- ipcs命令,显示了与POSIX:XSI进程间通信资源有关的信息
ipcs [-qms] [-a | -bcopt]
- 小写的-q, -s, -m选项用对象ID分别指定要删除的消息队列,信号量集或共享内存段
- 大写的选项使用初始的创建键(creation key)
POSIX:XSI信号量集
POSIX:XSI信号量由一个信号量元素(semaphore element)数组组成
信号量元素与Dijsktra提出的标准的整数信号量类似,但两者并不完全相同。
进程可以在单个调用中对整个集合执行操作
将POSIX:XSI信号量称为信号量集(semaphore set),与POSIX:SEM信号量区分开
每个信号量元素中至少包含下列信息
- 一个表示信号量元素值的非负整数(semval)
- 最后一个操纵信号量元素的进程的进程ID(sempid)
- 等待信号量元素值增加的进程的数量(semncnt)
- 等待信号量元素值变为零的进程的数量(semzcnt)
信号量主要的数据结构是semid_ds,它是在sys/sem.h中定义的
每个信号量元素都有两个与之相关的队列:
- 一个等待信号量值变为0的进程队列
- 一个等待信号量值增加的进程对量
信号量元素操作允许进程阻塞,知道信号量元素值为0或者它增加到一个大于零的特定值为止
信号量集的创建
- semget
(393) 暂时不看
面向连接的通信
服务,是指由服务器代表客户机执行的动作
客户机-服务器模型出现在计算机系统的很多层面
例如:
- 在面向对象的程序中,一个对象去调用另一个对象的方法,就被称为对象的客户(client of the object)
- 在系统层,管理诸如打印机之类的资源的守护进程就是系统用户(客户)的服务器
- 在因特网中,浏览器是向Web服务器请求资源的客户机进程
客户机-服务器模型的关键要素
- 由客户,而不是服务提供者,发起动作
- 服务器被动地等待来自客户机的请求
- 客户机和服务器通过一条通信信道连接起来,它们通过通信端点来访问这个通信信道
面对不可预料的客户机行为时,服务器要能够健全地处理多个同时发出的客户机请求。
在客户机-服务器交互动作的过程中,捕捉错误并采取适当行动的重要性
服务器要长时间的运行,并且必须能够释放分配给独立的客户机请求的所有资源
通信信道
通信信道(communication channel),是信息的逻辑通道,通信的参与者通过通信端点对其进行访问
信道可以是共享的或私有的,单工的或双工的。双工信道可以是对称的或不对称的
信道和底层的物理管道有所区别,物理管道可以支持多种类型的信道
在面向对象的编程中,客户机通过调用一个方法来和对象进行通信
命名管道有一个相关的路径名,执行命令
mkfifo
时,系统会在文件系统目录中创建一个对应于这个路径名的条目文件系统提供了底层的管道。进程通过调用
open
来创建通信端点,并通过文件描述符来访问这些端点命名管道可用于短的用户请求
当请求很长或服务器必须做出响应时,命名管道会面临一些困难。
- 如果服务器只是简单地为响应打开另一个命名管道,就不能保证各个客户机一定能读到发送给它们的相应
- 如果服务器为每个相应打开一个唯一的管道,那么客户机和服务器就必须事先对命名约定进行协商
- 另外,命名管道具有持久性,除非管道所有者显示地将其删除,否则它们始终存在。当交互各方不再存在时,一个通用的通信机制应该释放它的资源
TCP(Transmission Control Protocol, 传输控制协议), 是面向连接的协议,它在可能并不可靠的通道上,为通信提供可靠的信道
面向连接(Connectionoriented),是指起始端(客户机)先建立一个与目的端(服务器)的连接,之后双方就都可以发送和接收消息了
在起始端和目的端之间,TCP通过一种被称为三次握手(three-way handshake)的消息交换方式建立连接
TCP通过接收端确认和重传来实现可靠通信。TCP还提供流量控制,这样发送端就不会用大量的信息将接收端淹没了
幸运的是,操作系统的网络子系统实现了TCP,所以协议交换的细节在进程级是不可见的
如果网络出现了故障,进程会在通信端点上检测出错误。
由于对服务的请求中包含可见的通信过程,从这个意义上讲,无连接和面向连接协议都是低层次的。
程序员要明确地知道服务器的位置,而且必须显示地命名要访问的特定的服务器
在网络环境中命名服务器和服务是个很难的问题。
标识服务器的一种显而易见的办法就是利用它的进程ID和主机ID。
但是,操作系统一般是根据进程的创建时间按时间顺序分配进程ID的,因此客户机不可能事先知道主机上一个特定服务器进程的进程号
指定一个服务最常用的方法是,使用主机地址(IP地址)和一个被称作端口号的整数。
采用这种方式时,服务器要监视一个或多个通信信道,这些通信信道与事先为特定服务指定的端口号相关联
客户机为通信显式地指定一个主机地址和一个端口号(有相关的主机名访问IP地址的库函数调用)
本章的重点是与由主机地址和端口号指定的服务器进行的面向连接的通信,通信采用了TCP/IP和流套接字
面向连接的服务器策略
一旦服务器收到一个请求,它就可以用很多不同的策略来处理这个请求
串行服务器(serial server),要在完全地处理好一个请求之后才能接受其他的请求
串行服务器一次只处理一个请求,因此处理像文件传输这样长寿命请求的繁忙的服务器不能采用串行服务器策略
什么是僵子进程?
- 僵进程(zombie),是一种已经执行完毕但没有被其父进程等待的进程
- 僵进程没有释放它所有的资源,所以系统最终会耗尽一些关键的资源,例如:内存或进程ID
线程化服务器(threaded server),服务器在它自己的进程空间创建一个线程,而不是创建子进程来处理客户机请求。
通用因特网通信接口
UICI(Universal Internet Communication Interface,通用因特网通信接口)库,为UNIX中的面向连接通信提供了简化接口
UICI,不是任何UNIX标准的一部分。
接口是由作者设计的,在隐藏了底层网络协议细节的同时,对网络通信的实质进行了抽象。
UICI是公开的,使用UICI的程序中应该包含uici.h头文件
使用套接字时,服务器创建一个通信端点(一个套接字)并将其与一个知名端口相关联(将套接字绑定到端口上)
在等待客户机请求之前,服务器要将套接字设置为被动的,这样套接字就可以接收客户机请求了(将套接字设置为监听状态)
一旦在这个端点检测到客户机连接请求,服务器就为此客户机的私有双工通信创建一个新的通信端点
客户机和服务器通过文件描述符进行读和写操作来实现对通信端点的访问。
当通信完成时,两端都关闭文件描述符,释放与此通信信道相关的资源
客户机-服务器通信中使用的UICI调用的典型顺序
- 服务器创建一个通信端点(u_open)并等待客户机发送请求(u_accept)
- u_accept函数返回一个私有通信文件描述符
- 客户机为服务器的通信创建一个通信端点(u_connect)
一旦它们之间建立了连接,客户机和服务器就可以在网络上用普通的read和write函数进行通信了
总之,UICI服务器按如下步骤工作:
- 打开一个知名的监听端口(u_open)。u_open函数返回一个监听文件描述符(listening file descriptor)
- 在监听文件描述符上等待连接请求(u_accept)。u_accept函数一直阻塞,直到有客户机请求连接为止,然后它返回一个通信文件描述符(communication file descriptor),并将这个文件描述符用作私有双工客户机-服务器通信的句柄
- 通过通信文件描述符(read和write)与客户机进行通信
- 关闭通信文件描述符(close)
UICI客户机按如下步骤工作:
- 连接到一个指定的主机和端口(u_connect)。连接请求返回与服务器进行双工通信时使用的通信文件描述符
- 通过通信文件描述符(read和write)与服务器通信
- 关闭通信文件描述符(close)
UICI的套接字实现
通过使用带有TCP的套接字实现UICI API的概况
- socket – 创建通信端点
- bind – 将端点与指定的端口相关联
- listen – 将端点设置为被动的监听者
- accept – 接收来自客户机的连接请求
- socket – 创建通信端点
- connect – 请求向服务器建立连接
服务器创建一个句柄(socket),将它与网络上的一个物理位置相关联(bind),然后设置挂起请求的队列长度(listen)
UICI的u_open函数中封装了这三个函数,它返回一个对应于被动或监听套接字的文件描述符,然后,服务器监听客户机的请求(accept)
客户机也创建一个句柄(socket),并将这个句柄与服务器的网络位置相关联(connect)
UICI的u_connect函数封装了这两个函数。
服务器和客户机句柄是文件描述符,有时也将它们称作通信端点(communication endpoint)或传输端点(transmission endpoint)
一旦客户机和服务器建立了连接,它们就可以通过普通的read和write调用进行通信了
socket函数,创建了一个通信端点并返回一个文件描述符
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数
domain
– 选择所用的协议族,AF_INET,代表IPv4type
–- SOCK_STREAM, 表示有序,可靠,双工,面向连接的字节流,通常由TCP实现
- SOCK_DGRAM, 通过定长的,不可靠消息提供无连接通信,通常由UDP实现
protocol
– 指定特定的通信type使用的协议。在大多数实现中,每个type参数只能使用一种协议。例如,SOCK_STREAM使用TCP, SOCK_DGRAM使用UDP
返回值:
- 成功 – 返回一个对应于套接字文件描述符的非负整数
- 失败 – 返回-1,并设置errno
使用面向连接的协议为因特网通信创建一个套接字通信端点
int sock;
if ((sock = socket(AF_INET, SOCK_STREAM, 0) == -1))
perror("Failed to create socket!\n);
bind函数,将套接字通信端点的句柄与一个特定的逻辑网络连接关联起来。因特网域协议用端口号来指定逻辑连接
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
参数:
socket
– 前一个socket函数调用返回的文件描述符*address
– 该结构中包含一个协议族名和与协议相关的信息address_len
– 是*address
结构中的字节数
返回值:
- 成功 – 返回0
- 失败 – 返回-1,并设置errno
因特网域用
struct sockaddr_in
代替struct sockaddr
POSIX规定应用程序在和套接字函数一同使用时,要将
struct sockaddr_in
强制转换成struct sockaddr
在
netinet/in.h
中定义的struct sockaddr_in
结构至少包含下列成员,这些成员都是用网络字节顺序来表示的sa_family_t sin_family; /* AF_NET */
in_port_t sin_port; /* port number */
struct in_addr sin_addr; /* IP address */
对因特网通信来说,sin_family的值为AF_INET, sin_port指的是端口号
struct in_addr结构有一个被称为s_addr的成员,s_addr成员是in_addr_t类型,装载了因特网地址的数字值
服务器可以将sin_addr.s_addr字段设置为INADDR_ANY,表示套接字应该接收任何一个主机网络接口上的连接请求
客户机将sin_addr.s_addr字段设置为服务器主机的IP地址
将端口8652与一个对应于打开的文件描述符sock的套接字相关联
sockaddr_in server; 1
2
3
4
5
6int sock;
server.sin_family = AF_INET;
server.sin_addr.s_addr = htonl(INADDR_ANY);
server.sin_port = htons((short)8652);
if (bind(sock, (struct sockaddr *)&server, sizeof(server)) == -1)
perror("Failed to bind the socket to port !\n);`htonl
和htons
将INADDR_ANY和8652的字节按照网络字节顺序重新排序htonl
函数对long(长整数)重新排序,将其从主机字节顺序转换为网络字节顺序htons
函数将short(短整数)重新排序为网络字节顺序
它们的镜像函数
ntohl
和ntohs
对整数进行重新排序,将整数从网络字节顺序转到主机字节顺序大尾数计算机先存储最高有效字节(most significant byte), 小尾数计算机先存储最低有效字节(least significant type) – 大端,小端
当使用不同字节存放次序的计算机进行通信时,整数的字节顺序会带来一个问题, 因为不同的计算机会对端口号这样的协议信息产生错误的理解
不幸的是,这两种字节存放次序都很常见
- SPARC结构(由Sun Microsystems公司开发)采用大数在先结构
- 而Intel结构采用小数在先结构
因特网协议规定网络字节顺序(network byte order),采用大数在先结构,POSIX要求某些套接字地址字段按网络字节顺序给出
socket函数创建了一个通信端点,而bind函数将这个通信端点与一个特定的网络地址相关联。
此时,客户机可以用套接字与服务器进行连接。要用套接字来接收连接请求,应用程序必须通过调用listen函数将套接字设置成被动状态
listen函数使底层的系统网络基础结构分配队列以承载那些待处理的连接请求
#include <sys/socket.h>
int listen(int socket, int backlog);
当客户机发出连接请求时,客户机和服务器网络子系统交换信息(TCP的三次握手)以建立连接
因为服务器可能正忙,所以主机的网络子系统会将客户机的连接请求排队,直到服务器准备好接收这些请求为止
如果服务器主机拒绝了客户机的连接请求,客户机会收到一个ECONNREFUSED错误。
参数:
- socket值,就是上一次socket调用返回的描述符,
- backlog,给出了允许排队等待的客户机请求数目的最大值
返回值:
- 成功 – 返回0
- 失败 – 返回-1,并设置errno
建立了一个被动的监听套接字(socket, bind, listen)之后,服务器通过调用accept函数来处理到来的客户机连接
#include <sys/socket.h>
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
accept的参数与bind的参数类似,
- 但是,bind函数要求在调用之前将
*address
字段填好,这样它才能知道服务器会在哪个端口和接口上接收连接请求 - 与之相反,accept函数用
*address
字段来返回与建立连接的客户机有关的信息。尤其要支出的是,struct sockaddr_in
结构的sin_addr
成员中包含一个s_addr
成员,这个成员中装载了客户机的因特网地址 - accept函数的
*address_len
参数的值指定了address指向的缓冲区的长度。在调用之前,要在这个参数中填上*address
结构的长度,调用之后,*address_len
中函数的是由accept调用实际填写的缓冲区字节数
- 但是,bind函数要求在调用之前将
返回值:
- 成功 – 返回对应于已接收套接字的非负文件描述符
- 失败 – 返回-1,并设置errno
客户机调用socket来建立一个传输端点,然后用connect来建立远程服务器知名端口的连接
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
connect像bind一样填写struct sockaddr结构
返回值:
- 成功 – 返回0
- 失败 – 返回-1,并设置errno
主机名和IP地址
对大多数网络库调用来说,主机名都必须映射成数字网络地址
作为系统配置的一部分,系统管理员要定义将名字翻译成网络地址的机制。这个机制可能包括本地表查询,如果必要的话,还可以对域名服务器进行查询。
域名服务(Domain Name Service, DNS),是整合因特网命名的粘合剂
一般来说,主机可以由它的名字或者地址来指定。程序中的主机名通常用ASCII字符串来标识
IPv4地址可以用二进制格式(采用与struct in_addr的s_addr字段一样的网络字节顺序)或人类易读的格式表示,这种易读的格式被称作点分十进制表示法(dotted-decimal notation)或因特网地址点分表示法(Internet address dot notation)
地址的点分形式是一个字符串,这个字符串的值是以小数点分隔,用十进制表示的四个字节
IPv4地址的二进制表示有4字节长。因此4字节地址没有为未来的因特网扩展提供足够的空间,所以这个协议的新版本IPv6,采用了16字节的地址结构
inet_addr和inet_ntoa函数在点分十进制表示法和struct sockaddr_in的struct in_addr字段中使用二进制网络字节顺序格式之间进行转换
inet_addr函数将采用点分十进制表示法的地址转换成采用网络字节顺序的二进制地址。得到的值可以直接存储在struct sockaddr_in的sin_addr.s_addr字段中
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
返回值:
- 成功 – 返回因特网地址
- 失败 – 返回-1
inet_ntoa函数,接收一个struct in_addr结构,这个结构中包含一个采用网络字节顺序的二进制地址,并返回相应的用点分十进制表示法表示的字符串
#include <arpa/inet.h>
char *inet_ntoa(const struct in_addr in);
二进制地址可以从
struct sockaddr_in
结构的sin_addr
字段中得到返回的字符串是静态分配的,因此在线程化应用程序中使用inet_ntoa可能不安全
返回值:
- 返回一个指向网络地址的指针,这个网络地址是用因特网标准的点分表示法表示的
将主机名转换成二进制地址的传统方法是调用gethostbyname函数
#include <netdb.h>
struct hostent *gethostbyname(const char *name);
函数将主机名字符串作为参数,并返回一个指向struct hostent结构的指针,该结构中包含相应主机的名字和地址信息
返回值:
- 成功 – 返回一个指向struct hostent指针
- 失败 – 返回一个NULL指针,并设置errno
从地址到名字的转换可以用gethostbyaddr实现,
#include <netdb.h>
struct hostent *gethostbyaddr(const void *addr, socklen_t len, int type);
对IPv4来说,type应该是AF_INET,len的值应该是4字节,参数addr应该指向一个struct in_addr结构
返回值:
- 成功 – 返回一个指向struct hostent结构的指针
- 失败 – 返回一个NULL指针,并设置errno
在主机名和地址之间进行转换的第二种方法是使用
getnameinfo
和getaddrinfo
,它们在2001年首次称为被认可的POSIX标准#include <sys/socket.h>
#include <netdb.h>
void freeaddrinfo(struct addrinfo *ai);
int getaddrinfo(const char *restrict nodename, const char *restrict servname, const struct addrinfo *restrict hints, struct addrinfo **restrict res);
int getnameinfo(const struct sockaddr *restrict sa, socklen_t salen, char *restrict node, socklen_t nodelen, char *restrict service, socklen_t servicelen, unsigned flags);
返回值:
- 成功 – 返回0
- 失败 – 返回一个错误码
使用uname来获取主机名
#include <sys/utsname.h>
int uname(struct utsname *name);
返回值:
- 成功 – 返回一个非负值
- 失败 – 返回-1,并设置errno
在
sys/utsname.h
中定义的struct ustname结构至少包含下列成员char sysname[]; /* 本OS实现的名字 */
char nodenamep[]; /* 在通信网络中本节点的名字 */
char release[]; /* 本实现当前发布的级别 */
char version[]; /* 本次发布的当前版本的级别 */
char machine[]; /* 系统正在运行的硬件类型名 */
WWW重定向 (497)
- 万维网采用客户机-服务器体系结构,这种结构基于一种资源表示方案(URI),一种通信协议(HTTP)和一种文档格式(HTML),三者共同作用使用户可以很方便地进行信息的访问和交互
万维网
- 万维网,是客户机和服务器的集合,这些客户机和服务器商定好以特定的格式来进行交互和信息交换
统一资源定位符
统一资源定位符(Universal Resource Locator, URL)的格式为 – 模式:位置(scheme : location)
- 模式(scheme),指的是访问资源的方法,例如HTTP
- 位置(location),则说明了资源放在哪里
当用户通过浏览器打开一个URL时,浏览器解析服务器的主机名并建立一个到那个服务器主机上指定端口的TCP连接
然后,浏览器通过下一节中描述的HTTP协议向服务器发送一个资源请求,请求URL的绝对路径指定的资源
HTTP入门
- 在客户端和Web服务器上都有一个被称为超文本传输协议(HyperText Transfer Protocol, HTTP)的特定的规则集合。
- 这个规则的集合也可以称为协议(protocol),客户机和服务器通过这个协议来交换信息
Web通信模式
根据HTTP的术语,客户(client),是建立连接的应用程序,服务器(server),是接受连接并做出相应的应用程序。
用户代理(user agent),是一个发起服务请求的客户。
根据这些术语,浏览器就既是客户又是用户代理
起源服务器(origin server),是一个拥有资源的服务器
隧道(tunnel),是一个充当盲中继(blind relay)的中间体,隧道不解析HTTP,而是将它传送给服务器。
隧道从客户端接受一个HTTP连接并建立一个到服务器的连接。在这种情况下,尽管它既不是用户代理也不是起源服务器,但根据HTTP的定义,隧道即充当客户端,又充当服务器
隧道将信息从客户端传递到服务器。当服务器响应时,隧道就将响应传送到客户端。
代理(proxy),是一种中间体,它在客户端和服务器之间,代表它的客户发起请求
客户通过一种特殊形式的GET向代理发出请求,而且代理必须解析HTTP。
与隧道一样,代理也是即充当客户端又充当服务器。但是,代理存在的时间通常很长,而且通常会充当多个客户端的中间体
透明代理(transparent proxy),除了在代理的标识和鉴权方面所需的修改之外,不对请求或应答进行修改
非透明代理(nontransparent proxy),可能会代表它们的客户端执行很多其他类型的服务,例如注释,匿名过滤,内容过滤,审查,媒体转换等
代理可以保存与它们的客户有关的统计信息和其他信息
Google这样的搜索引擎是另外一种类型的代理,它缓存了与页面内容和指向页面的URL有关的信息。用户可以通过关键词或短语来访问缓存的信息
代理代表客户执行的最重要的服务就是高速缓存。
高速缓存(cache),是响应信息在本地的存储。浏览器通常会将近期的响应消息缓存在磁盘上。当用户打开一个URL时,浏览器首先查看在磁盘上能够找到资源,只有当它在本地找不到对象时,才会启动一个网络请求
代理高速缓存(proxy cache),将它读取的资源存储起来,以便更有效地为将来申请这些资源的请求服务
通常,代理高速缓存都安装在局域网的网管上。本地网络中的客户通过这个代理来转发它所有的请求。
可以用代理高速缓存的本地存储中的对象来响应来自不同用户的请求。如果已经有人请求过这个对象,而且代理缓存了这个对象,那么对当前请求的响应就要快得多了
可以将代理看成客户端的中间体,那么,网关(gateway),则是服务端的机制。
网关可以接收请求,就像它是起源服务器一样。网关可以位于局域网的边界路由器上,也可以位于保护内部网的防火墙之外。
网关可以提供很多服务,例如安全,翻译和负载平衡。网关可以作为某个组织的一群Web服务器的公共接口,也可以作为位于防火墙之内的Web服务器的前端门户使用
网关和隧道有什么不同?
- 隧道,是一种管道,它将信息从一个点传到另一个点,不对信息进行修改。
- 网关,则充当资源的前端,可能还会充当一群服务器的前端
各种服务器的常见的缺陷和错误
线程和时序错误
这类程序的大部分时序错误都是由于对TCP的不正确理解造成的。
就算提供了一个足够大的缓冲区,也不要假定在单个读操作中就能读入整个请求。TCP提供了对一个没有分组和消息便捷的字节流的抽象。
未捕捉到的错误和错误的退出
你的服务器对错误会有什么样的响应,服务器在什么时候应该退出,如果没有认真或正确解决这些问题,运行的程序对程序可能就是一个很大的威胁,尤其是当以很高的特权级别运行时
服务器通常应该一直运行下去,直到系统重启为止,因此要考虑退出的策略。不要从除了main函数之外任何其他的函数中退出。
总的来说,其他的函数或者应该对错误进行处理,或者应该向调用程序返回一个错误码。客户不应当会造成服务器的退出。只有由于资源(内存,描述符等)缺乏出现了无法恢复的错误而危及到未来的正确执行时,服务器才能退出
即使库函数返回了一个错误,C程序仍然不加理会地继续执行,就有可能在后继的执行中造成一个致命的,实际上无法定位的错误。
要避免这类问题,就要对每一个能够返回错误的库函数的返回值进行检查
释放资源总是很重要的。在服务器中,这是非常关键的。当客户端的通信结束时,要关闭所有相应的文件描述符。如果函数分配了缓冲区,就一定要在某个地方将其释放掉。查看那些资源是否在函数执行的每条路径上都被释放了,要特别注意出现错误时会发生什么情况
确定函数什么时候应该输出一条错误消息,什么时候应该返回一个错误码。用条件编译将通知性的消息放在源代码中,但不要让它们出现在发布的应用程序中
记住:
- 在现实世界里,这些消息都应该有地方可去,可能是到一些不走运的控制台日志里去
- 将消息写到标准错误而不是标准输出中去
- 通常,标准错误会被重定向到一个控制台日志中去。同样,系统不对标准错误进行缓冲,因此出现错误时,消息就会显示出来
编程错误和不好的风格
要避免大的或不相容的缩进。也要避免大的循环,可以使用函数来降低复杂性
不要做重复的工作。如果可能,就使用库函数,此外,还要合并通用的代码
随时都要释放分配了的资源,例如缓冲区,但是不要多次释放它们,因为这样会造成随后的资源分配失败
第22章 服务器性能(572)
三种客户机服务器通信模型
- 串行服务器
- 父-服务器
- 线程化服务器
因为父-服务器策略对每个客户端请求都创建一个新的子进程,有时也被称为每个请求一个进程策略(process-per-request)
类似的,线程化服务器策略对每个请求创建一个单独的线程,所以也经常被称为每个请求一个线程策略(thread-per-request)
一种变通的策略是在接收请求之前,先创建一些进程或线程,构建一个工作者池(worker pool)
工作者们都在同步点阻塞,等待请求到达。每个到达的请求激活一个线程或进程,其余的则继续阻塞。
工作者池消除了创建线程或进程的开销,但是带来了额外的同步开销。同时,性能和池的大小密切相关。
灵活的实现可能会动态调整池中的线程或进程数量来维持系统的平衡
缓冲区池的方法能用一个子进程的池来实现吗?
- 通信文件描述符是小的整型数值,用来表示文件描述符表中的一个位置。
- 这个数值只在同一个进程内的上下文之间有意义,所以用子进程来实现缓冲区池是不可能的
在每个请求一个线程(thread-per-request)的体系结构中,主线程阻塞在accept调用上,并为每个请求创建一个线程。
而在工作者池的方法中,池的大小限制了竞争资源的并发线程数。每个请求一个线程的设计,如果没有仔细的监视,就容易发生资源过度分配
何为每个请求一个进程(process-per-request)策略?如何实现它?
- 每个请求一个进程的策略类似每个请求一个线程的策略。
- 服务器接收请求,创建子进程(而不是创建新线程)来处理它。
- 因此主进程在得到了通信文件描述符之后才去创建子进程,子进程继承了其文件描述符表,所以该通信文件描述符对于子进程也是有效的