学习计时:共5小时程序员 读书:编程 代码:数组 做业:安全 博客:服务器 |
1、学习目标网络 |
1. 掌握三种并发的方式:进程、线程、I/O多路复用
2. 掌握线程控制及相关系统调用
3. 掌握线程同步互斥及相关系统调用
|
逻辑控制流在时间上重叠,那么它们就是并发的。
并发(concurrency ) ,出如今计算机系统的许多不一样层面上。多线程应用级并发是颇有用的:
- 访问慢速I/O设备。
- 与人交互。
- 经过推迟工做以下降延迟。
- 服务多个网络客户端。
- 在多核机器上进行并行计算。并发
使用应用级并发的应用程序称为并发程序 (concurrent program)。现代操做系统提供了三种基本的构造并发程序的方法:ide
进程。每一个逻辑控制流都是一个进程,由内核来调度和维护。由于进程 有独立的虚拟地址空间,想要和其余流通讯,控制流必须使用某种显式的进程间通讯 (interprocess communication, IPC) 机制。函数
I/O 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们本身的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另外一个状态。由于程序是一个单独的进程,因此全部的流都共享同一个地址空间。
线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。是其余两种方式的混合体,像进程流同样由内核进行调度,而像I/O 多路复用流同样共享同一个虚拟地址空间。
构造并发程序最简单的方法就是用进程。
一个构造并发服务器的天然方法就是,在父进程中接受客户端链接请求,而后建立一个新的子进程来为每一个新客户端提供服务。服务器正在监昕一个监听描述符(好比描述符 3)上的链接请求。如今假设服务器接受了客户端 1 的链接请求, 并返回一个已链接描述符(好比描述符4)。
在接受链接请求以后,服务器派生一个子进程,这个子进程得到服务器描述符表的完整拷贝。子进程关闭它的拷贝端的链接请求中的监听描述符 3,而父进程关闭它的己链接描述符 4 的拷贝,由于再也不须要这些描述符了。
其中子进程正忙于为客户端提供服务。由于父、子进程中的已链接描述符都指向同一个文件表表项,因此父进程关闭它的已链接描述符的拷贝是相当重要的。不然,将永远不会释放已链接描述符 4 的文件表条目,并且由此 引发的存储器泄漏将最终消耗尽可用的存储器,使系统崩溃。
第一步:服务器接受客户端的链接请求:
父进程为客户端 1 建立了子进程以后,它接受一个新的客户端 2 的链接请求, 并返回一个新的已链接描述符(好比描述符5),而后,父进程又派生另外一个子进程,这个子进程用已链接描述符 5 为它的客户端提供服务。
此时,父进程正在等待下一个链接请求,而两个子进程正在并发地为它们各自的客户端提供服务。
第二步:服务器派生一个子进程为这个客户端服务:
第三步:服务器接受另外一个链接请求:
- 一般服务器会运行很长的时间,因此咱们必需要包括一个 SIGCHLD 处理程序,来回收僵死 (zombie) 子进程的资源。由于当 SIGCHLD 处理程序执行时, SIGCHLD 信号是阻塞的,而 Unix 信号是不排队的,因此 SIGCHLD 处理程序必须准备好回收多个僵死子进程的资源。
- 父子进程必须关闭它们各自的 connfd 拷贝。这对父进程而言尤其重要,它必须关闭它的已链接描述 符,以免存储器泄漏。
- 由于套接字的文件表表项中的引用计数,直到父子进程的 connfd 都关闭了,到客户端的链接才会终止。
第四步:服务器派生另外一个子进程为新的客户端服务
基于进程的并发 echo 服务器.父进程派生一个子进程来处理每一个新的链接请求:
在父、子进程间共享状态信息,进程有一个很是清晰的模型 : 共享文件表,可是不共享用户地址空间。进程有独立的地址空间既是优势也是缺点。一个进程不可能不当心覆盖另外一个进程的虚拟存储器,这就消除了许多使人迷惑的错误一一这是一个明显的优势。
另外一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通讯)机制。基于进程的设计的另外一个缺点是,它们每每比较慢,由于进程控制和 IPC 的开销很高。
I/O 多路复用(I/O multiplexing) 技术。基本的思路就是使用 select 函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序
select函数:
select 函数处理类型为 fd_set 的集合,也叫作描述符集合。逻辑上,咱们将描述符集合当作一个大小为 n 的位向量。
每一个位 bk对应于描述符 k。当且仅当 bk= 1, 描述符 k才代表是描述符集合的一个元素。只容许你对描述符集合作三件事: 1) 分配它们, 2) 将一个此种类型的变量赋值给另外一个变量, 3) 用 FD_ZERO、 FD_SET、 FD_CLR 和 FD_ISSET 宏指令来修改和检查它们。select 函数有两个输人 : 一个称为读集合的描述符集合(fdset)和该 读集合的基数 (n) (其实是任何描述符集合的最大基数). select 函数会一直阻塞,直到读集合中至少有一个描述符准备好能够读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符 k就表示准备好能够读了。做为一个反作用, select 修改了参数 fdset 指向的 fd_set,指明读集合中一个称为准备好集合 (ready set) 的子集,这个集合是由读集合中准备好能够读了的描述符组成的。函数返回的值指明了准备好集合的基数。注意,因为这个反作用, 咱们必须在每次调用 select 时都更新读集合。
使用 I/O 多路复用的 echo 服务器。服务器使用 select 等待监听描述符上的链接请求和标准输入上的命令:
不是调用 accept 函数来等待一个链接请求,而是调用 select 函数,这个函数会一直阻塞,直到监听描述符或者标准输入准备好能够读。
一旦 select 返回,咱们就用 FD_ISSET 宏指令来判断哪一个描述符准备好能够读了。
一旦它链接到某个客户端,就会连续回送输入行,直到客户端关闭这个链接中它的那一端。所以,若是你键入一个命令到标准输入,你将不会获得响应,直到服务器和客户端之间结束。一个 更好的方法是更细粒度的多路复用,服务器每次循环〈至多)回送一个文本行。
I/O 多路复用能够用作并发事件驱动 (event-driven) 程序的基础,在事件驱动程序中,流是由于某种事件而前进的
将逻辑流模型化为状态机。不严格地说,一个状态机 (state machine) 就是一组状态 (state)、输入事件(input event) 和转移他(transition),其中转移就是将状态和输入事件映射到状态。每一个转移都将一个(输入状态,输入事件)对映射到一个输出状 态。自循环(self-loop) 是同一输入和输出状态之间的转移。节 点表示状态,有向弧表示转移,而弧上的标号表示输入事件。一个状态机从某种初始状态开始执行。每一个输入事件都会引起一个从当前状态到下一状态的转移。
并发事件驱动 echo 服务器中逻辑流的状态机:
服务器使用I/O多路复用,借助 select 函数检测输入事件的发生。
服务器调用 select 函数来 检测两种不一样类型的输人事件: a) 来自一个新客户端的链接请求到达, b) 一个己存在的客户 端的己链接描述符准备好能够读了。
基于I/O 多路复用的并发 echo 服务器。每次服务器迭代都回送来自每一个准备好的描述符的文本行:
init_pool 函数初始化客户端池。 clientfd 数组表示已链接描述符的集合, 其中整数 -1 表示一个可用的槽位。初始时,已链接描述符集合是空的,并且监听描述符是 select 读集合中惟一的描述符。
init_pool: 初始化活动客户端池:
add_clieht函数添加一个新的客户端到活动客户端池中。在 clientfd 数组中找到一个空槽位后,服务器将这个已链接描述符添加到数组中,并初始化相应的RIO读缓冲区,这样一来咱们就可以对这个描述符调用rio_readlineb。将这个已链接描述符添加到 select 读集合,并更新该池的一些全局属性。 maxfd 变量记录了 select 的最大文件描述符。 maxi 变量记录的 是到 clientfd数组的最大索引,这样 check_clients 函数就无需搜索整个数组了。
check_clients 函数回送来自每一个准备好的已链接描述符的一个文本行。 若是成功地从描述符读取了一个文本行,那么咱们就将该文本行回送到客户端
add_client: 向池中添加一个新的客户端链接:
check_clients: 为准备好的客户端链接服务:
select 函数检测到输入事件,而 add_client 函数建立 一个新的逻辑流(状态机). check_clients 函数经过回送输入行来执行状态转移,并且当客 户端完成文本行发送时,它还要删除这个状态机。
事件驱动设计的一个优势是,它比基于进程的设计给了程序员更多的对程序行为的控制。
另外一个优势是,一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因 此每一个逻辑流都能访问该进程的所有地址空间。
事件驱动设计的一个明显的缺点就是编码复杂。咱们的事件驱动的并发 echo 服务器须要的代码比基于进程的服务器多三倍。不幸的是,随着并发粒度的减少,复杂性还会上升。这里的粒度是指每一个逻辑流每一个时间片执行的指令数量。
线程(thread) 就是运行在进程上下文中的逻辑流。
每一个线程都有它本身的线程上下文 (thread context),包括一个惟一的整数线程 (Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。全部的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于 I/O 多路复用的流的特性。同进程同样,线程由内核自动调度,而且内核经过一个整数 ID 来识别线程。同基于 I/O 多路复用的流同样,多个线程 运行在单一进程的上下文中,所以共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
每一个进程开始生命周期时都是单一线程,这个线程称为主线程 (main thread)。在某一时刻,主线程建立一个对等线程 (peer thread),从这个时间点开始,两个线程就并发地运行。最后,因 为主线程执行一个慢速系统调用。或者由于它被系统的间隔计时器中断, 控制就会经过上下文切换传递到对等线程。对等线程会执行一段时间,而后控制传递回主线程,依次类推。
由于一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比 进程的上下文切换快得多。另外一个不一样就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池 (pool),独立于其余线程建立的线程。主线程和其余线程的区别仅在于它老是进程中第一个运行的线程。对等 (线程)池概念的主要影响是,一个线程可 以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每一个对等线程都能读写相同的共享数据。
并发线程执行:
Posix 线程 (Pthreads) 是在 C 程序中处理线程的一个标准接口。Pthreads 定义了大约 60 个函数,容许程序建立、杀死和回收线程,与对等线程安全地共享数据,还能够通知对等线程系统状态的变化。
线程的代码和本地数据被封装在一个线程例程(thread routine) 中。若是想传递多个参数给钱程例程,那么你应该将参数放 到一个结构中,并传递一个指向该结构的指针。想要线程例程返回多个参数,你能够返回一个指向一个结构的指针。
线程经过调用 pthread create 函数来建立其余线程。
pthread_create 函数建立一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新建立线程的默认属性。
当 pthread_create 返回时,参数 tid包含新建立线程的ID。新线程能够经过调用 pthread_self 函数来得到它本身的线程 ID.
一个线程是如下列方式之一来终止的 :
- 当顶层的线程例程返回时,线程会隐式地终止。
- 经过调用 pthread_exit 函数,线程会显式地终止。若是主线程调用 pthread_exit , 它会等待全部其余对等线程终止,而后再终止主线程和整个进程,返回值为 thread_return。
线程经过调用 pthread_join 函数等待其余线程终止。
pthread_join 函数会阻塞,直到线程 tid 终止,将线程例程返回的 (void*) 指针赋值 为 thread_return 指向的位置,而后回收己终止线程占用的全部存储器资源。
pthread join 函数只能等待一个指定的线程终止。
在任何一个时间点上,线程是可结合的 (joinable) 或者是分离的 (detached)。一个可结合的线程可以被其余线程收回其资源和杀死。在被其余线程回收以前,它的存储器资源(例如栈)是没有被释放的。相反,一个分离的线程是不能被其余线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。
默认状况下,线程被建立成可结合的。为了不存储器泄漏,每一个可结合线程都应该要么被其余线程显式地收回,要么经过调用 pthread_detach 函数被分离。
pthread_detach 函数分离可结合线程 tid. 线程可以经过以 pthread_self ()为参数 的 pthread_detach 调用来分离它们本身。
pthread_once 函数容许你初始化与线程例程相关的状态。
once_control 变量是一个全局或者静态变量,老是被初始化为 PTHREAD_ONCE_INIT。 当你第一次用参数 once_control 调用 pthread_once 时, 它调用 init_routine,这是一个没有输入参数,也不返回什么的函数。
动态初始化多个线程共享的全局变量时, pthread_once 函数是颇有用的。
调用 pthread_ create 时,如何将已链接描述符传递给对等线程。最明显的方法就是传递一个指向这个描述符的指针。
对等线程间接引用这个指针,并将它赋值给一个局部变量。
这样可能会出错,由于它在对等线程的赋值语句和主线程的 accept 语句间引入了竞争 (race)。若是赋值语句在下一个 accept 以前完成,那么对等线程中的局部变量 connfd 就获得正确的描述符值。然而,若是赋值语句是在 accept 以后才完成的,那么对等线程中的 局部变量 connfd 就获得下一次链接的描述符值。那么不幸的结果就是,如今两个线程在同一 个描述符上执行输入和输出。为了不这种潜在的致命竞争,咱们必须将每一个 accept 返回的 已链接描述符分配到它本身的动态分配的存储器块
是在线程例程中避免存储器泄漏。既不显式地收回线程,就必须分离 每一个线程,使得在它终止时它的存储器资源可以被收回。更进一步,咱们必须当心释放主线程分配的存储器块
一组并发线程运行在一个进程的上下文中。每一个线程都有它本身独立的线程上下文,包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每一个线程和其余线程一块儿共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及全部的共享库代码和数据区域组成的。线程也共享一样的打开文件的集合。
让一个线程去读或写另外一个线程的寄存器值是不可能的。另外一方 面,任何线程均可以访问共享虚拟存储器的任意位置。
保存在虚拟地址空间的栈区域中,而且一般是被相应的线程独立地访问的。
线程化的 C 程序中变量根据它们的存储类型被映射到虚拟存储器:
- 全局变量。
- 本地自动变量。
- 本地静态变量。
一个变量 v 是共亭的,当且仅当它的一个实例被一个以上的线程引用。
同步错误。
将线程 i 的循环代码分解成五个部分:
- Hi:在循环头部的指令块
- Li:加载共享变量 cnt 到寄存器%eaxi 的指令,这里%eaxi 表示线程i 中的寄存器%eax的值。
- Ui:更新(增长) %eaxi 的指令。
- Si:将%eaxi 的更新值存回到共享变量 cnt 的指令。
- Ti:循环尾部的指令块。
注意头和尾只操做本地栈变量,而 Li、 Ui 和 Si操做共享计数器变量的内容。
通常而言, 你没有办法预测操做系统是否将为你的线程选择一个正确的顺 序。
进度图 (progress graph) 的方法来阐明这些正确 的和不正确的指令顺序的概念
进度图 (progress graph) 将 n 个并发线程的执行模型化为一条 n 维笛卡儿空间中的轨迹线。每条轴 k对应于线程 k 的进度。每一个表明线程 k 已经完成了指令 Ik 这一状态。图的原点对应于没有任何线 程完成一条指令的初始状态。
进度图将指令执行模型化为从一种状态到另外一种状态的转换(transition)。转换被表示为一条从一点到相邻点的有向边。合法的转换是向右(线程 1 中的一条指令完成〉或者向上(线程 2 中的一条指令完成)的。两条指令不能在同一时刻完成一一对角线转换是不容许的。程序决不会反向运行,因此向下或者向左移动的转换也是不合法的。
一个程序的执行历史被模型化为状态空间中的一条轨迹线。
绕开不安全区的轨迹线叫作安全轨迹线 (safe trajectory)。 相反,接触到任何不安全区的轨迹线就叫作不安全轨迹线 (unsafe trajectory)。
一种解决同步不一样执行线程问题的方法,这种方法是基于一种叫作信号量 (semaphore) 的特殊类型变量的。信号量 s 是具备非负整数值的全 局变量,只能由两种特殊的操做来处理,这两种操做称为 P 和 V:
- P(s) :若是 s 是非零的,那么 P 将 s 减1,而且当即返回。若是 s 为零,那么就挂起这个线程, 直到 s变为非零,而一个 V操 做会重启这个线程。在重启以后, P 操做将 s 减1,并将控制 返回给调用者。
- V(s): V操做将s 加 1。若是有 任何线程阻塞在P 操做等待 s 变成非零,那么 V操做会重启 这些线程中的一个,而后该线程将s 减1,完成它的 P 操做。
信号量的函数:
sem_init 函数将信号量 sem 初始化为 value. 每一个信号量在使用前必须初始化。针对我 们的目的,中间的参数老是0。程序分别经过调用 sem_wait 和 sem_post 函数来执行 P 和 V 操做。
P 和 V的包装函数:
基本思想是将每一个共享变量 (或者一组相关的共享变量)与一个信号量 s (初始为1)联系起来,而后用 P(s) 和V(s) 操做将 相应的临界区包围起来。
保护共享变量的信号量叫作二元信号量 (binary semaphore),由于它的值老是 0 或者 1 。以提供互斥为目的的二元信号量经常也称为互斥锁 (mutex)。在一个互斥锁上执行 P 操做称为对互斥锁加锁。相似地,执行 V操做称为对互斥锁解锁。对一个互斥锁加了锁可是尚未解锁的线程称为占用这个互斥锁。一个被用做一组可用资源的计数器的信号量称为计数信号量。
关键思想是这种 P 和 V操做的结合建立了一组状态, 叫作禁止区 (forbidden region),其中 s 使用信号量来互斥。 s
由 P 和 V操做建立的禁止区使得在任什么时候间点上,在被包围的临 界区中,不可能有多个线程在执行指令。换句话说,信号量操做确保了对临界区的互斥访问。
除了提供互斥以外,信号量的另外一个重要做用是调度对共享资源的访问。
生产者一消费者问题。生产者产生项目并把它们插入到一个有限的缓冲区中。消费者从缓冲区中取出这些项目,而后消费它们
由于插入和取出项目都涉及更新共享变量,因此咱们必须保证对缓冲区的访问是互斥的。可是只保证互斥访问是不够的,咱们还须要调度对缓冲区的访问。若是缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之类似,若是缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。
基于预线程化 (prethreading) 的有趣的并发服务器。 SBUP 操做类型为 sbuf_t 的有限缓冲区,项目存放在一个动态分配的 n 项整数数组 (buf) 中。 front 和 rear 索引值记录该数组中的第一项和最后一项。三个信号量同步对缓 冲区的访问。 mutex 信号量提供互斥的缓冲区访问。 slots 和 items 信号量分别记录空槽位和可用项目的数量。
sbuf_t: SBUF 包使用的有限缓冲区:
sbuf_init 函数为缓冲区分配堆存储器,设置 front 和 rear 表示一个空的缓冲区,并为三个信号量赋初始值。这个函数在调用其余三个函数中的任何一个以前调用一次。 sbuf_deinit函数是当应用程序使用完缓冲区时,释放缓冲区存储的。 sbuf_insert 函数等待一个可用的槽位,对互斥锁加锁,添加项目,对互斥锁解锁,而后宣布有一个新项目可用。 sbuf_remove 函数是与 sbuf_insert 函数对称的。在等待一个可用的缓冲区项目以后,对互斥锁加锁,从缓冲区的前面取出该项目,对互斥锁解锁,而后发信号通知一个新的槽位可供使用。
SBUF: 同步对有限缓冲区并发访问的包:
预线程化的并发服务器的组织结构。一组现有的线程不断地取出和处理来自有限缓冲区的已链接描述符:
顺序、并发和并行程序集合之间的关系:
并行程序经常被写为每一个核上只运行一个线程。
并行程序的加速比 (speedup) 一般定义为:
p 是处理器核的数量,凡是在 k个核上的运行时间。这个公式有时称为强扩展 (strong scaling)。当 T1 是程序顺序执行版本的执行时间时, Sp 称为绝对加速比.(absolute speedup)。当 T1 是程序并行版本在一个核上的执行时间时, Sp 称为相对加速比 (relative speedup)。绝对加速 比比相对加速比能更真实地衡量并行的好处。
效率 (efficiency ) ,定义为
一般表示为范围在 (0, 100] 之间的百分比。效率是对因为并行化形成的开销的衡量。具备高 效率的程序比效率低的程序在有用的工做上花费更多的时间,在同步和通讯上花费更少的时间。
加速比还有另一面,称为弱扩展 (weak scaling),在增长处理器数量的同时,增长问题的规模,这样随着处理器数量的增长,每一个处理器执行的工做量保持不变。加速比和效率被表达为单位时间完成的工做总量。
当用线程编写程序时,咱们必须当心地编写那些具备称为线程安全性(thread safety) 属性的画数。一个函数被称为线程安全的(thread-safe),当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。若是一个函数不是线程安全的,咱们就说它是线程不安全的(thread-unsafe)。
四个(不相交的)线程不安全函数类:
- 第 1 类: 不保护共享变量的函数。thread 函数中对一个未受保护的全局计数器变量加 1. 将这类线程不安全函数变成线程安全的, 相对而言比较容易:利用像P和 V操做这样的同步操做来保护共享的变量。这个方法的优势是在调用程序中不须要作任何修改。缺点是同步操做将减慢程序的执行时间。
- 第 2 类:保持跨越多个调用的状态的函数。一个伪随机数生成器是这类线程不安全函数的简单例子。伪随机数生成器程序包. rand 函数是线程不安全的,由于当前调用的结果依赖于前次调用的中间结果。当调用 srand 为 rand 设置了一个种子后,咱们从一个单线程中反复地调用 rand,可以预期获得一个可重复的随机数字序列。然而,若是多线程调用 rand 函数,这种假设就再也不成立了。
使得像 rand这样的函数线程安全的惟一方式是重写它,使得它再也不使用任何 static 数据,而是依靠调用者在参数中传递状态信息。这样作的缺点是,程序员如今还要被迫修改调用程序中的代码。在一个大的程序中,可能有成百上千个不一样的调用位置,作这样的修改将是很是麻烦的,并且容易出错。
- 第 3 类:返回指向静态变量的指针的函数。某些函数,例如 ctime 和 gethostbyname,将计算结果放在一个 static 变量中,而后返回一个指向这个变量的指针。若是咱们从并发线程中调用 这些函数,那么将可能发生灾难,由于正在被一个线程使用的结果会被另外一个线程悄悄地覆盖了。有两种方法来处理这类线程不安全函数。一种选择是重写函数,使得调用者传递存放结果的变量的地址。这就消除了全部共享数据,可是它要求程序员可以修改函数的源代码。
- 第 4 类:调用线程不安全函数的函数。若是函数f调用线程不安全函数 g,那么f就是线程不安全的吗?不必定。若是 g是第 2 类函数,即依赖于跨越屡次调用的状态,那么f也是线程不安全的,并且除了重写 g 之外,没有什么办法。然而,若是 g 是第 1 类或者第 3 类函数,那么只 要你用一个互斥锁保护调用位置和任何获得的共享数据,f仍然多是线程安全的。
可重入函数 (reentrant function),其特色在于它们具备这 样一种属性:当它们被多个线程调用时,不会引用任何共享数据。
可重入函数一般要比不可重人的线程安全的函数高效一些,由于它们不须要同步操做。更进一步来讲,将第 2 类线程不安全函数转化为线 程安全函数的惟一方法就是重写它,使之变为可重入的。
可重入函数、线程安全函数和线程不安全函数之间的集合关系:
检查某个函数的代码并先验地判定它是可重入的。
若是全部的函数参数都是传值传递的(即没有指针),而且全部的数据引用都是本地的自动栈变量(即没有引用静态或全局变量),那么函数就是显式可重入的 (explicitly reentrant),也就是说,不管它是被如何调用的,咱们均可以断言它是可重入的。
咱们老是使用术语可重入的 (reenntrant) 既包括显式可重入函数也包括隐式可重入函数。然而,认识到可重入性有时既是调用者也是被调用者的属性,并不仅是被调用者单独的属性是很是重要的。
大多数 Unix 函数,包括定义在标准 C 库中的函数(例如 malloc、 free、 realloc、 printf 和 scanf) 都是线程安全的,只有一小部分是例外。
常见的线程不安全的库函数:
除了 rand 和 strtok 之外,全部这些线程不安全函数都是第 3 类的,它们返回一个指向静态变量的指针。若是咱们须要在一个线程化的程序中调用这些函数中的某一个,对调用者来讲最不惹麻烦的方法是加锁-拷贝。然而,加锁-拷贝方法有许多缺点。首先,额外的同步下降了 程序的速度。其次,像 gethostbyname 这样的函数返回指向复杂结构的结构的指针,要拷贝整个结构层次,须要深层拷贝 (deep copy) 结构。再次,加锁-拷贝方法对像 rand 这样依赖 跨越调用的静态状态的第 2 类函数并不有效。 所以,Unix系统提供大多数线程不安全函数的可重人版本。可重入版本的名字老是以"_r" 后缀结尾。
当一个程序的正确性依赖于一个线程要在另外一个线程到达y 点以前到达它的控制流中的 x 点时,就会发生竞争。
发生竞争是由于程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另外一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工做。
信号量引人了一种潜在的使人厌恶的运行时错误,叫作死锁(deadlock) ,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。
- 程序员使用 P 和 V操做顺序不当,以致于两个信号量的禁止区域重叠。若是某个执行轨迹 线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,由于重叠的禁止区域阻塞了 每一个合法方向上的进展。换句话说,程序死锁是由于每一个线程都在等待其余线程执行一个根本不可能发生的V操做。
- 重叠的禁止区域引发了一组称为死所区域(deadlock region )的状态。若是一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线能够进人死锁区域, 可是它们不可能离开。
- 死锁是一个至关困难的问题,由于它不老是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其余的将会陷入这个区域。
程序死锁有不少缘由,要避免死锁通常而言是很困难的。然而,当使用二元信号量来实现互斥时,能够应用下面的简单而有效的规则来避免死锁:
互斥锁加锁顺序规则:若是对于程序中每对互斥锁 (s, t), 每一个同时占用 s 和 t 的线程都按照相同的顺序对它们加锁,那么这个程序就是无死锁的。
有死锁程序的进度图:
无死锁程序的进度图:
一个并发程序是由在时间上重叠的一组逻辑流组成的。 三种不一样的构建并发程序的机制:进程、I/O 多路复用和线程。 进程是由内核自动调度的,并且由于它们有各自独立的虚拟地址空间,因此要实现共享数 据,必需要有显式的 IPC 机制。事件驱动程序建立它们本身的并发逻辑流,这些逻辑流被模型化为状态机,用I/O 多路复用来显式地调度这些流。由于程序运行在一个单一进程中,因此在流之间共享数据速度很快并且很容易。线程是这些方法的综合。同基于进程的流同样,线程也是由内核自动调度的。同基于 I/O 多路复用的流同样,线程是运行在一个单一进程的上下文中的,因 此能够快速而方便地共享数据。 不管哪一种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的 P 和 V操做就是为了帮助解决这个问题。信号量操做能够用来提供对共享数据的互斥访问,也对诸如生产者一消费者程序中有限缓冲区和读者一写者系统中的共享对象这样的资源访问进行调度。 并发也引人了其余一些困难的问题。被线程调用的函数必须具备一种称为线程安全的属性。 可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数一般比不可重人函数更为有效,由于它们不须要任何同步原语。竞争和死锁是并发程序中出现的另外一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。 四类线程不安全的函数。