20135321余佳源——信息安全系统设计基础第十三周学习总结

20135321余佳源——信息安全系统设计基础第十三周学习总结

标签(空格分隔): 20135321余佳源


第十三周(11.30-12.06):
学习计时:共10小时
读书:5
代码:2
作业:1
博客:2

一、学习目标

  1. 掌握三种并发的方式:进程、线程、I/O多路复用
  2. 掌握线程控制及相关系统调用
  3. 掌握线程同步互斥及相关系统调用

第 11 章 网络编程

11.1 客户端-服务器编程模型

每个网络应用都是基于客户端一服务器模型的。一个应用是由一个服务器进程和一个或者多个客户端进程组成。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。
客户端一服务器模型中的基本操作是事务,由四步组成:

  1. 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。
  2. 服务器收到请求后,解释它,并以适当的方式操作它的资源。
  3. 服务器给客户端发送一个响应,并等待下一个请求。
  4. 客户端收到响应并处理它。

一个客户端-服务器事务:

11.2 网络

客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。
对于一个主机而言,网络只是又一种I/O设备,作为数据源和数据接收方。

一个网络主机的硬件组成:

物理上而言,网络是一个按照地理远近组成的层次系统。最低层是 LAN (Local Area Network,局域网),最流行的局域网技术是以太网 (Ethernet)。
一个以太网段 (Ethernet segment) 包括一些电缆(通常是双绞线)和一个叫做集线器的小盒子。

每个以太网适配器都有一个全球唯一的 48 位地址,它存储在这个适配器的非易失性存储器上。一台主机可以发送 一段位,称为帧(frame),到这个网段内其他任何主机。每个帧包括一些固定数量的头部 (header) 位,用来标识此帧的源和目的地址以及此帧的长度,此后紧随的就是数据位的有效载荷。每个主机适配器都能看到这个帧,但是只有目的主机实际读取它。

使用一些电缆和叫做网桥 (bridge) 的小盒子,多个以太网段可以连接成较大的局域网,称为桥接以太网 (bridged Ethernet)

桥接以太网:

网桥比集线器更充分地利用了电缆带宽。

多个不兼容的局域网可以通过叫做路由器 (router)的特殊计算机连接起来,组成一个internet(互联网络)。

Internet 和 internet 我们总是用小写字母的 internet 描述一般概念, 而用大写字母的Internet 来描述一种具体的实现,也就是所谓的全球 IP 因特网。

路由器可以用来由各种局域网和广域网构建互联网络。

互联网络至关重要的特性是,它能由采用完全不同和不兼容技术的各种局域网和广域网组成。

运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差异。提供两种基本能力 :

  1. 命名机制。不同的局域网技术有不同和不兼容的方式来为主机分配地址。互联网络协议通过定义一种一致的主机地址格式消除了这些差异。每台主机会被分配至少二个这种互联网络地址 (internet address),这个地址唯一地标识了这台主机。

  2. 传送机制。在电缆上编码位和将这些位封装成帧方面,不同的联网技术有不同的和不兼容的方式。互联网络协议通过定义一种把数据位捆扎成不连续的片(称为包)的统一方式,从而消除了这些差异。一个包是由包头和有效载荷组成的,其中包头包括包的大小以及源主机和目的主机的地址,有效载荷包括从源主机发出的数据位。

在互联网络上,数据是如何从一台主机传送到另一台主机的:

11.3 全球 IP 因特网

每台因特网主机都运行实现 TCP/IP 协议 (Transmission Control Protocol/Internet Protocol,传输控制协议/互联网络协议)的软件,几乎每个现代计算机系统都支持这个协议。

因特网的客户端和服务器混合使用套接字接口函数和 Unix I/O 函数来进行通信。套接字函数典型地是作为会陷入内核的系统调用来实现 的,并调用各种内核模式的 TCP/IP 函数。

TCP/IP 实际上是一个协议族,其中每一个都提供不同的功能。

  • IP 协议提供基本的命名方法和递送机制,这种递送机制能够从一台因特网主机往其他主机发送包,也叫做数据报 (datagram). IP 机制从某种意义上而言是不可靠的,因为,如果数据报在网络中丢失或者重复, 它并不会试图恢复。
  • UDP(Unreliable Datagram Protocol,不可靠数据报协议)稍微扩展了 IP 协议, 这样一来,包可以在进程间而不是在主机间传送。
  • TCP 是一个构建在 IP 之上的复杂协议,提供 了进程间可靠的全双工(双向的)连接。

把因特网看做一个世界范围的主机集合,满足以下特性:

  • 主机集合被映射为一组 32 位的 IP 地址。
  • 这组IP地址被映射为一组称为因特网域名 (lnternet domain name) 的标识符。
  • 因特网主机上的进程能够通过连接(connection )和任何其他因特网主机上的进程通信。

11.3.1 IP地址

一个 IP 地址就是一个 32 位无符号整数。

IP地址结构:

因为因特网主机可以有不同的主机字节顺序, TCP/IP 为任意整数数据项定义了统一的网络字节顺序 (network byte order) (大端字节顺序)。

IP 地址,它放在包头中跨过网络被携带。

IP 地址结构中存放的地址总是以(大端法)网络宇节顺序存放的,即使主机字节顺序 (host byte order) 是小端法。

网络和主机字节顺序间转换:

htonl 函数将 32 位整数由主机字节顺序转换为网络字节顺序。 ntohl 函数将 32 位整数从网络宇节顺序转换为主机字节。 htons和 ntohs 函数为 16 位的整数执行相应的转换。

IP 地址通常是以一种称为点分十进制表示法来表示的。

因特网程序使用 inet_aton 和 inet_ntoa 函数来实现 IP 地址和点分十进制串之间的转换:

inet_aton 函数将一个点分十进制串 (cp) 转换为一个网络字节顺序的 IP 地址 (inp)。相似地, inet_ntoa 函数将一个网络字节顺序的 IP 地址转换为它所对应的点分十进制串。

对 inet_aton 的调用传递的是指向结构的指针,而对 inet_ntoa 的调用传递的是结构本身。

"n" 表示的是网络(network)。"a" 表示应用(application)。而 "to" 表示转换。

11.3.2 因特网域名

因特网客户端和服务器互相通信时使用的是 IP 地址。

子树称为子域 (subdomain)。 层次结构中的第一层是 一个未命名的根节点。下一层是一组一级域名 (first-level domain name)。
常见的第一层域名包括 com、 edu、 gov、org 和 net。
下一层是二级 (second-level) 域名,例如 cmu. edu

一旦一个组织得到了一个二级域名,那么它就可以在这个子域中创建任何新的域名了。

因特网定义了域名集合和 IP 地址集合之间的映射。

因特网应用程序通过调用 gethostbyname 和 gethostbyaddr 函数,从 DNS 数据库中检索任意的主机条目。

gethostbyname 函数返回和域名 name 相关的主机条目。 gethostbyaddr 函数返回和 IP 地址 addr 相关联的主机条目。第二个参数给出了一个 IP 地址的字节长度,对于目前的因特网而言总是四个字节。对于我们的要求来说,第三个参数总是0。

每台因特网主机都有本地定义的域 名localhost,这个域名总是映射为本地回送地祉 (loopback address) 127.0.0.1

  • 最简单的情况下,一个域名和一个 IP 地址之间是一一映射的
  • 某些情况下,多个域名可以映射为同一个 IP 地址
  • 最通常的情况下,多个域名可以映射到多个 IP 地址
  • 某些合法的域名没有映射到任何 IP 地址

11.3.3 因特网连接

因特网客户端和服务器通过在连接上发送和接收字节流来通信。
连接是点对点的。
从数据可以同时双向流动的角度来说,它是全双工的。可靠的。

一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和 一个 16 位的整数端口组成的,用"地址 : 端口"来表示。当客户端发起一个连接请求时,客户 端套接字地址中的端口是由内核自动分配的,称为临时端口(ephemeral port)。服务器套接字地址中的端口通常是某个知名的端口,是和这个服务相对应的。

Web 服务器通常使用端口 80,而电子邮件服务器使用端口 25。

一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对 (socket pair) ,由下列元组来表示:

cliaddr 是客户端的 IP 地址, cliport 是客户端的端口, servaddr 是服务器的 IP 地址, 而 servport 是服务器的端口。

因特网连接分析:

11.4 套接字接口

套接字接口 (socket interface) 是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络 应用。

套接字接口描述:

11.4.1 套接字地址结构

套接字结构:

_in 后缀是互联网络 (internet) 的缩写,而不是输入 (input) 的缩写。

connect、 bind 和 accept 函数要求一个指向与协议相关的套接字地址结构的指针。

11.4.2 socket 函数

客户端和服务器使用 socket 函数来创建一个套接字描述符

调用 socket 函数:

AF_INET 表明我们正在使用因特网,而 SOCK_STREAM 表示这个套接字是因特网连接的一个端点。 socket 返回的 clientfd 描述符仅是部分打开的,还不能用于读写。

11.4.3 connect 函数

客户端通过调用 connect 函数来建立和服务器的连接。

connect 函数试图与套接字地址为 serv addr 的服务器建立一个因特网连接,其中 addrlen 是 sizeof(sockaddr in). connect 函数会阻塞,一直到连接成功建立或是发生错误。如果成功, sockfd描述符现在就准备好可以读写了,并且得到的连接是由套接字对
(x:y, serv_addr.sin_addr:serv_addr.sin_port)

x 表示客户端的 IP 地址,而 y 表示临时端口,它唯一地确定了客户端主机上的客户端进程。

11.4.4 open_clientfd 函数

open_clientfd 函数和运行在主机 hostname 上的服务器建立一个连接,并在知名端口 port 上监听连接请求。它返回一个打开的套接宇描述符,该描述符准备好了,可以用 Unix I/O 函数做输入和输出。

11.4.5 bind 函数

bind、 listen 和 accept 被服务器用来和客户端建立连接。

bind 函数告诉内核将 my_addr 中的服务器套接字地址和套接字描述符 sockfd 联系起来。 参数 addrlen 就是 sizeof(sockaddr_in) 。

11.4.6 listen 函数

客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为 socket 函数创建的描述符对应于主动套接字 (active socket),它存在 于一个连接的客户端。服务器调用 listen 函数告诉内核,描述符是被服务器而不是客户端使用的。

listen 函数将 sockfd 从一个主动套接字转化为一个监听套接字 (listening socket),该套接字可以接受来自客户端的连接请求。 backlog 参数暗示了内核在开始拒绝连接请求之前,应该放人队列中等待的未完成连接请求的数量。

11.4.7 open_listenfd 函数

socket、 bind 和 listen 函数结合成一个叫做。open_listenfd 的辅助函数

open_listenfd 函数打开和返回一个监听描述符,这个描述符准备好在知名端口 port 上接收连接请求。

11.4.8 accept 函数

accept 函数来等待来自客户端的连接请求

accept 函数等待来自客户端的连接请求到达侦听描述符 listenfd,然后在 addr 中填写客户端的套接字地址,并返回一个巳连接描述符 (connected descriptor),这个描述符可被用来利用 Unix I/O 函数与客户端通信。

监听描述符是作为客户端连接请求的一个端点。典型地,它被创建一次,并存在于服务器的整个生命周期。已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时都会创建一次, 它只存在于服务器为一个客户端服务的过程中。

监听描述符和己连接描述符的角色:

11.4.9 echo 客户端和服务器的示例

  • 服务器一次一个地在客户端间迭代,称为迭代服务器 (iterative server)。
  • 并发服务器 (concurrent server),它能够同时处理多个客户端。

11.5 Web 服务器

11.5.1 Web 基础

Web 客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做 HTTP (Hypertext Transfer Protocol,超文本传输协议). HTTP 是一个简单的协议。一个 Web 客户端(即浏览器) 打开一个到服务器的因特网连接,并且请求某些内容。服务器响应所请求的内容,然后关闭连接。浏览器读取这些内容,并把它显示在屏幕上。

Web 内容可以 用一种叫做 HTML (Hypertext Markup Language,超文本标记语言)的语言来编写。一个 HTML 程序(页)包含指令(标记),它们告诉浏览器如何显示这页中的各种文本和图形对象。

11.5.2 Web 内容

Web 服务器以两种不同的方式向客户端提供内容:

  1. 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容 (static content), 而返回文件给客户端的过程称为服务静态内容 (serving static content)。
  2. 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为态内容 (dynamic content),而运行程序并返回它的输出到客户端的过程称为服务动态内容 (serving dynamic content)。

MIME 类型示例:

每条由 Web 服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做 URL (Universal Resource Locator,通用资源定位符)。

URL 的后缀:

  • 确定一个 URL 指向的是静态内容还是动态内容没有标准的规则。每个服务器对它所管理的文件都有自己的规则。一种常见的方法是,确定一组目录,例如 cgi-bin,所有的可执行性文件都必须存放这些目录中。
  • 后缀中的最开始的那个"/"不表示 Unix 的根目录。相反,它表示的是被请求内容类型 的主目录。例如,可以将一个服务器配置成这样: 所有的静态内容存放在目录 /usr/ httpd/html 下,而所有的动态内容都存放在目录 /usr/httpd/cgi-bin 下。
  • 最小的 URL 后缀是"/"字符,所有服务器将其扩展为某个默认的主页,例如 /index. html。这解释了为什么简单地在浏览器中键人一个域名就可以取出一个网站的主页。浏览器在 URL 后添加缺失的 "/", 并将之传递给服务器,服务器又把"/"扩展到某个默认的文件名。

11.5.3 HTTP 事务

因为 HTTP 是基于在因特网连接上传送的文本行的,我们可以使用 Unix 的TELNET程序来和因特网上的任何 Web 服务器执行事务。

1.HTTP 请求

一个 HTTP 请求的组成是这样的:一个请求行 (request line) (第 5 行),后面跟随零个或更多个请求报头 (request header) (第 6 行),再跟随一个空的文本行来终止报头列表

HTTP 支持许多不同的方法,包括 GET、 POST、 OPTIONS、 HEAD、 PUT、 DELETE 和 TRACE。

GET 方法指导服务器生成和返回URI (Uniform Resource Identifier,统一资源标识符)标识的内容。 URI是相应的 URL 的后缀,包括文件名和可选的参数。

代理缓存 (proxy cache) 会使用 Host 报头,这个代理 缓存有时作为浏览器和管理被请求文件的原始服务器 (origin server) 的中介。客户端和原始服务器之间,可以有多个代理,即所谓的代理链 (proxy chain)。 Host 报头中的数据指示了原始服务器 的域名,使得代理链中的代理能够判断它是否可以在本地缓存中拥有一个被请求内容的副本。

2.HTTP 晌应

一个 HTTP 响应的组成是这样的:一个响应行 (response line) (第 8 行)后面跟随着零个或更多的响应报头 (response header) (第 9 ~ 13 行), 再跟随一个终止报头的空行(第 14 行),再跟随一个响应主体 (response body)

状态码 (status code) 是一个三位的正整数, 指明对请求的处理。状态消息 (status message) 给出与错误代码等价的英文描述。

11.5.4 服务动态内容

1.客户端如何将程序参数传递给服务器

2.服务器如何将参数传递给子进程

3.服务器如何将其他信息传递给子进程

4.子进程将它的输出发送到哪里

11.6 综合: TINYWeb 服务器

  1. TINY 的 main 程序
  2. doit 函数
  3. clienterror 函数
  4. read_requesthdrs 函数
  5. parse_uri 函数
  6. serve static 函数
  7. serve_dynamic 函数

11.7 小结

每个网络应用都是基于客户端-服务器模型的。
一个应用是由一个服务器和一个或多个客户端组成的。服务器管理资源,以某种方式操作资源,为它的客户端提供服务。
客户端一服务器模型中的基本操作是客户端一服务器事务,它是由客户端请求和跟随其后的服务器响应组成的。
可以把因特网看成是一个全球范围的主机集合,具有以下几个属性:1)每个因特网主机都有一个唯一的 32 位名字,称为它的 IP 地址。 2) IP 地址的集合被映射为一个因特网域名的集合。 3) 不同因特 网主机上的进程能够通过连接互相通信。
客户端和服务器通过使用套接字接口建立连接。一个套接字是连接的一个端点,连接是以文件描述符的形式提供给应用程序的。套接字接口提供了打开和关闭套接宇描述符的函数。客户端和服务器通过读写这些描述符来实现彼此间的通信。
Web 服务器使用 HTTP 协议和它们的客户端(例如浏览器)彼此通信。浏览器向服务器请 求静态或者动态的内容。对静态内容的请求是通过从服务器磁盘取得文件并把它返回给客户端来服务的。对动态内容的请求是通过在服务器上一个子进程的上下文中运行一个程序并将它的 输出返回给客户端来服务的。CGI 标准提供了一组规则,来管理客户端如何将程序参数传递给服务器,服务器如何将这些参数以及其他信息传递给子进程,以及子进程如何将它的输出发送回客户端。







第 12 章 并发编程

逻辑控制流在时间上重叠,那么它们就是并发的。
并发(concurrency ) ,出现在计算机系统的许多不同层面上。

应用级并发是很有用的:

  • 访问慢速I/O设备。当一个应用正在等待来自慢速 I/O 设备(例如磁盘)的数据到达时, 内核会运行其他进程,使 CPU保持繁忙。每个应用都可以按照类似的方式,通过交替执行 I/O 请求和其他有用的工作来使用并发。
  • 与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力。例如,他们在打印一个文档时,可能想要调整一个窗口的大小。现代视窗系统利用并发来提供这种能力。每次用户请求某种操作(比如通过单击鼠标)时,一个独立的并发逻辑流被创建来执行这个操作。
  • 通过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它们,利用并发来降低某些操作的延迟。比如,一个动态存储分配器可以通过推迟合并,把它放到一个运行在较低优先级上的并发"合并"流中,在有空闲的 CPU 周期时充分利用这些空闲 周期,从而降低单个 free 操作的延迟。
  • 服务多个网络客户端。
  • 在多核机器上进行并行计算。许多现代系统都配备有多核处理器,多核处理器中包含多个 CPU。被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行得快,因为这些流会并行执行,而不是交错执行。

使用应用级并发的应用程序称为并发程序 (concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:

进程。每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程 有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信 (interprocess communication, IPC) 机制。


I/O 多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。


线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。是其他两种方式的混合体,像进程流一样由内核进行调度,而像I/O 多路复用流一样共享同一个虚拟地址空间。

12.1 基于进程的并发编程

构造并发程序最简单的方法就是用进程。
一个构造并发服务器的自然方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

服务器正在监昕一个监听描述符(比如描述符 3)上的连接请求。现在假设服务器接受了客户端 1 的连接请求, 并返回一个已连接描述符(比如描述符4)。
在接受连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝端的连接请求中的监听描述符 3,而父进程关闭它的己连接描述符 4 的拷贝,因为不再需要这些描述符了。
其中子进程正忙于为客户端提供服务。因为父、子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的拷贝是至关重要的。否则,将永远不会释放已连接描述符 4 的文件表条目,而且由此 引起的存储器泄漏将最终消耗尽可用的存储器,使系统崩溃。

第一步:服务器接受客户端的连接请求:

父进程为客户端 1 创建了子进程之后,它接受一个新的客户端 2 的连接请求, 并返回一个新的已连接描述符(比如描述符5),然后,父进程又派生另一个子进程,这个子进程用已连接描述符 5 为它的客户端提供服务。
此时,父进程正在等待下一个连接请求,而两个子进程正在并发地为它们各自的客户端提供服务。

第二步:服务器派生一个子进程为这个客户端服务:

第三步:服务器接受另一个连接请求:

12.1.1 基于进程的并发服务器

  1. 通常服务器会运行很长的时间,所以我们必须要包括一个 SIGCHLD 处理程序,来回收僵死 (zombie) 子进程的资源。因为当 SIGCHLD 处理程序执行时, SIGCHLD 信号是阻塞的,而 Unix 信号是不排队的,所以 SIGCHLD 处理程序必须准备好回收多个僵死子进程的资源。
  2. 父子进程必须关闭它们各自的 connfd 拷贝。这对父进程而言尤为重要,它必须关闭它的已连接描述 符,以避免存储器泄漏。
  3. 因为套接字的文件表表项中的引用计数,直到父子进程的 connfd 都关闭了,到客户端的连接才会终止。

第四步:服务器派生另一个子进程为新的客户端服务

基于进程的并发 echo 服务器.父进程派生一个子进程来处理每个新的连接请求:

12.1.2 关于进程的优劣

在父、子进程间共享状态信息,进程有一个非常清晰的模型 : 共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。一个进程不可能不小心覆盖另一个进程的虚拟存储器,这就消除了许多令人迷惑的错误一一这是一个明显的优点。
另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和 IPC 的开销很高。

12.2 基于 I/O 多路复用的并发编程

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 宏指令来判断哪个描述符准备好可以读了。

一旦它连接到某个客户端,就会连续回送输入行,直到客户端关闭这个连接中它的那一端。因此,如果你键入一个命令到标准输入,你将不会得到响应,直到服务器和客户端之间结束。一个 更好的方法是更细粒度的多路复用,服务器每次循环〈至多)回送一个文本行。

12.2.1 基于 I/O 多路复用的并发事件驱动服务器

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 函数通过回送输入行来执行状态转移,而且当客 户端完成文本行发送时,它还要删除这个状态机。

12.2.2 I/O 多路复用技术的优劣

事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。
另一个优点是,一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因 此每个逻辑流都能访问该进程的全部地址空间。
事件驱动设计的一个明显的缺点就是编码复杂。我们的事件驱动的并发 echo 服务器需要的代码比基于进程的服务器多三倍。不幸的是,随着并发粒度的减小,复杂性还会上升。这里的粒度是指每个逻辑流每个时间片执行的指令数量。

12.3 基于线程的并发编程

线程(thread) 就是运行在进程上下文中的逻辑流。
每个线程都有它自己的线程上下文 (thread context),包括一个唯一的整数线程 (Thread ID, TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。
基于线程的逻辑流结合了基于进程和基于 I/O 多路复用的流的特性。同进程一样,线程由内核自动调度,并且内核通过一个整数 ID 来识别线程。同基于 I/O 多路复用的流一样,多个线程 运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。

12.3.1 线程执行模型

每个进程开始生命周期时都是单一线程,这个线程称为主线程 (main thread)。在某一时刻,主线程创建一个对等线程 (peer thread),从这个时间点开始,两个线程就并发地运行。最后,因 为主线程执行一个慢速系统调用。或者因为它被系统的间隔计时器中断, 控制就会通过上下文切换传递到对等线程。对等线程会执行一段时间,然后控制传递回主线程,依次类推。

因为一个线程的上下文要比一个进程的上下文小得多,线程的上下文切换要比 进程的上下文切换快得多。另一个不同就是线程不像进程那样,不是按照严格的父子层次来组织的。和一个进程相关的线程组成一个对等(线程)池 (pool),独立于其他线程创建的线程。主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。对等 (线程)池概念的主要影响是,一个线程可 以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据。

并发线程执行:

12.3.2 Posix 线程

Posix 线程 (Pthreads) 是在 C 程序中处理线程的一个标准接口。Pthreads 定义了大约 60 个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。

线程的代码和本地数据被封装在一个线程例程(thread routine) 中。如果想传递多个参数给钱程例程,那么你应该将参数放 到一个结构中,并传递一个指向该结构的指针。想要线程例程返回多个参数,你可以返回一个指向一个结构的指针。

12.3.3 创建线程

线程通过调用 pthread create 函数来创建其他线程。

pthread_create 函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f。能用attr参数来改变新创建线程的默认属性。

当 pthread_create 返回时,参数 tid包含新创建线程的ID。新线程可以通过调用 pthread_self 函数来获得它自己的线程 ID.

12.3.4 终止线程

一个线程是以下列方式之一来终止的 :

  • 当顶层的线程例程返回时,线程会隐式地终止。

  • 通过调用 pthread_exit 函数,线程会显式地终止。如果主线程调用 pthread_exit , 它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为 thread_return。

  • 某个对等线程调用 Unix 的 exit 函数,该函数终止进程以及所有与该进程相关的线程。

  • 另一个对等线程通过以当前线程ID作为参数调用 pthread_cancle 函数来终止当前线程。

12.3.5 回收已终止线程的资源

线程通过调用 pthread_join 函数等待其他线程终止。

pthread_join 函数会阻塞,直到线程 tid 终止,将线程例程返回的 (void*) 指针赋值 为 thread_return 指向的位置,然后回收己终止线程占用的所有存储器资源。

pthread join 函数只能等待一个指定的线程终止。

12.3.6 分离线程

在任何一个时间点上,线程是可结合的 (joinable) 或者是分离的 (detached)。一个可结合的线程能够被其他线程收回其资源和杀死。在被其他线程回收之前,它的存储器资源(例如栈)是没有被释放的。相反,一个分离的线程是不能被其他线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。
默认情况下,线程被创建成可结合的。为了避免存储器泄漏,每个可结合线程都应该要么被其他线程显式地收回,要么通过调用 pthread_detach 函数被分离。
pthread_detach 函数分离可结合线程 tid. 线程能够通过以 pthread_self ()为参数 的 pthread_detach 调用来分离它们自己。

12.3.7 初始化线程

pthread_once 函数允许你初始化与线程例程相关的状态。

once_control 变量是一个全局或者静态变量,总是被初始化为 PTHREAD_ONCE_INIT。 当你第一次用参数 once_control 调用 pthread_once 时, 它调用 init_routine,这是一个没有输入参数,也不返回什么的函数。

动态初始化多个线程共享的全局变量时, pthread_once 函数是很有用的。

12.3.8 一个基于线程的并发服务器

调用 pthread_ create 时,如何将已连接描述符传递给对等线程。最明显的方法就是传递一个指向这个描述符的指针。
对等线程间接引用这个指针,并将它赋值给一个局部变量。

这样可能会出错,因为它在对等线程的赋值语句和主线程的 accept 语句间引入了竞争 (race)。如果赋值语句在下一个 accept 之前完成,那么对等线程中的局部变量 connfd 就得到正确的描述符值。然而,如果赋值语句是在 accept 之后才完成的,那么对等线程中的 局部变量 connfd 就得到下一次连接的描述符值。那么不幸的结果就是,现在两个线程在同一 个描述符上执行输入和输出。为了避免这种潜在的致命竞争,我们必须将每个 accept 返回的 已连接描述符分配到它自己的动态分配的存储器块

是在线程例程中避免存储器泄漏。既不显式地收回线程,就必须分离 每个线程,使得在它终止时它的存储器资源能够被收回。更进一步,我们必须小心释放主线程分配的存储器块

12.4 多线程程序中的共享变量

12.4.1 线程存储器模型

一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
让一个线程去读或写另一个线程的寄存器值是不可能的。另一方 面,任何线程都可以访问共享虚拟存储器的任意位置。
保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。

12.4.2 将变量映射到存储器

线程化的 C 程序中变量根据它们的存储类型被映射到虚拟存储器:

  • 全局变量。全局变量是定义在函数之外的变量。在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。
  • 本地自动变量。本地自动变量就是定义在函数内部但是没有 static 属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。
  • 本地静态变量。本地静态变量是定义在函数内部并有 static 属性的变量。和全局变量一样,虚拟存储器的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

12.4.3 共享变量

一个变量 v 是共亭的,当且仅当它的一个实例被一个以上的线程引用。

12.5 用信号量同步线程

同步错误。

将线程 i 的循环代码分解成五个部分:

  • Hi:在循环头部的指令块
  • Li:加载共享变量 cnt 到寄存器%eaxi 的指令,这里%eaxi 表示线程i 中的寄存器%eax的值。
  • Ui:更新(增加) %eaxi 的指令。
  • Si:将%eaxi 的更新值存回到共享变量 cnt 的指令。
  • Ti:循环尾部的指令块。

注意头和尾只操作本地栈变量,而 Li、 Ui 和 Si操作共享计数器变量的内容。

一般而言, 你没有办法预测操作系统是否将为你的线程选择一个正确的顺 序。

进度图 (progress graph) 的方法来阐明这些正确 的和不正确的指令顺序的概念

12.5.1 进度图

进度图 (progress graph) 将 n 个并发线程的执行模型化为一条 n 维笛卡儿空间中的轨迹线。每条轴 k对应于线程 k 的进度。每个代表线程 k 已经完成了指令 Ik 这一状态。图的原点对应于没有任何线 程完成一条指令的初始状态。

进度图将指令执行模型化为从一种状态到另一种状态的转换(transition)。转换被表示为一条从一点到相邻点的有向边。合法的转换是向右(线程 1 中的一条指令完成〉或者向上(线程 2 中的一条指令完成)的。两条指令不能在同一时刻完成一一对角线转换是不允许的。程序决不会反向运行,所以向下或者向左移动的转换也是不合法的。

一个程序的执行历史被模型化为状态空间中的一条轨迹线。

绕开不安全区的轨迹线叫做安全轨迹线 (safe trajectory)。 相反,接触到任何不安全区的轨迹线就叫做不安全轨迹线 (unsafe trajectory)。

12.5.2 信号量

一种解决同步不同执行线程问题的方法,这种方法是基于一种叫做信号量 (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 操作。

P 中的测试和减 1 操作是不可分割的,也就是说,一旦预测信号量s 变为非零,就会将s 减 1,不能有中断。V中的加 1 操作也是不可分割的,也就是加载、加 1 和存储信号量的过程中没 有中断。注意, V的定义中没有定义等待线程被重新启动的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程。

P 和 V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性 (semaphore invariant),为控制并发程序的轨迹线提供了强有力的工具

信号量的函数:

sem_init 函数将信号量 sem 初始化为 value. 每个信号量在使用前必须初始化。针对我 们的目的,中间的参数总是0。程序分别通过调用 sem_wait 和 sem_post 函数来执行 P 和 V 操作。

P 和 V的包装函数:

12.5.3 使用信号量来实现互斥

基本思想是将每个共享变量 (或者一组相关的共享变量)与一个信号量 s (初始为1)联系起来,然后用 P(s) 和V(s) 操作将 相应的临界区包围起来。

保护共享变量的信号量叫做二元信号量 (binary semaphore),因为它的值总是 0 或者 1 。以提供互斥为目的的二元信号量常常也称为互斥锁 (mutex)。在一个互斥锁上执行 P 操作称为对互斥锁加锁。类似地,执行 V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器的信号量称为计数信号量。

关键思想是这种 P 和 V操作的结合创建了一组状态, 叫做禁止区 (forbidden region),其中 s<O。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器的值。

使用信号量来互斥。 s<O 的不可行状态定义了一个禁止区,禁止区完全包括了不安全 区,阻止了实际可行的轨迹线接触到不安全区:

由 P 和 V操作创建的禁止区使得在任何时间点上,在被包围的临 界区中,不可能有多个线程在执行指令。换句话说,信号量操作确保了对临界区的互斥访问。

12.5.4 利用信号量来调度共享资源

除了提供互斥之外,信号量的另一个重要作用是调度对共享资源的访问。

1. 生产者-消费者问题

生产者一消费者问题。生产者产生项目并把它们插入到一个有限的缓冲区中。消费者从缓冲区中取出这些项目,然后消费它们

因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问是互斥的。但是只保证互斥访问是不够的,我们还需要调度对缓冲区的访问。如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目变为可用。

基于预线程化 (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: 同步对有限缓冲区并发访问的包:

2. 读者-写者问题

有些线程只读对象,而其他的线程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。一般来说,有无限多个并发的读者和写者。

第一类读者-写者问题,读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。第二类读者一写者问题,写者优先,要求一旦一个写者准备好可以写,它就会尽可能快地完成它的写操作。

对这两种读者-写者问题的正确解答可能导致饥饿 (starvation),饥饿就是一个线程无限期地阻塞,无法进展。

12.5.5 综合: 基于预线程化的并发服务器

预线程化的并发服务器的组织结构。一组现有的线程不断地取出和处理来自有限缓冲区的已连接描述符:

12.6 使用线程提高并行性

顺序、并发和并行程序集合之间的关系:

并行程序常常被写为每个核上只运行一个线程。
并行程序的加速比 (speedup) 通常定义为:

p 是处理器核的数量,凡是在 k个核上的运行时间。这个公式有时称为强扩展 (strong scaling)。当 T1 是程序顺序执行版本的执行时间时, Sp 称为绝对加速比.(absolute speedup)。当 T1 是程序并行版本在一个核上的执行时间时, Sp 称为相对加速比 (relative speedup)。绝对加速 比比相对加速比能更真实地衡量并行的好处。

效率 (efficiency ) ,定义为

通常表示为范围在 (0, 100] 之间的百分比。效率是对由于并行化造成的开销的衡量。具有高 效率的程序比效率低的程序在有用的工作上花费更多的时间,在同步和通信上花费更少的时间。

加速比还有另外一面,称为弱扩展 (weak scaling),在增加处理器数量的同时,增加问题的规模,这样随着处理器数量的增加,每个处理器执行的工作量保持不变。加速比和效率被表达为单位时间完成的工作总量。

12.7 其他并发问题

12.7.1 线程安全

当用线程编写程序时,我们必须小心地编写那些具有称为线程安全性(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仍然可能是线程安全的。

12.7.2 可重入性

可重入函数 (reentrant function),其特点在于它们具有这 样一种属性:当它们被多个线程调用时,不会引用任何共享数据。
可重入函数通常要比不可重人的线程安全的函数高效一些,因为它们不需要同步操作。更进一步来说,将第 2 类线程不安全函数转化为线 程安全函数的唯一方法就是重写它,使之变为可重入的。

可重入函数、线程安全函数和线程不安全函数之间的集合关系:

检查某个函数的代码并先验地断定它是可重入的。
如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用静态或全局变量),那么函数就是显式可重入的 (explicitly reentrant),也就是说,无论它是被如何调用的,我们都可以断言它是可重入的。

我们总是使用术语可重入的 (reenntrant) 既包括显式可重入函数也包括隐式可重入函数。然而,认识到可重入性有时既是调用者也是被调用者的属性,并不只是被调用者单独的属性是非常重要的。

12.7.3 在线程化的程序中使用已存在的库函数

大多数 Unix 函数,包括定义在标准 C 库中的函数(例如 malloc、 free、 realloc、 printf 和 scanf) 都是线程安全的,只有一小部分是例外。

常见的线程不安全的库函数:

除了 rand 和 strtok 以外,所有这些线程不安全函数都是第 3 类的,它们返回一个指向静态变量的指针。如果我们需要在一个线程化的程序中调用这些函数中的某一个,对调用者来说最不惹麻烦的方法是加锁-拷贝。然而,加锁-拷贝方法有许多缺点。首先,额外的同步降低了 程序的速度。其次,像 gethostbyname 这样的函数返回指向复杂结构的结构的指针,要拷贝整个结构层次,需要深层拷贝 (deep copy) 结构。再次,加锁-拷贝方法对像 rand 这样依赖 跨越调用的静态状态的第 2 类函数并不有效。 因此,Unix系统提供大多数线程不安全函数的可重人版本。可重入版本的名字总是以"_r" 后缀结尾。

12.7.4 竞争

当一个程序的正确性依赖于一个线程要在另一个线程到达y 点之前到达它的控制流中的 x 点时,就会发生竞争。
发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。

12.7.5 死锁

信号量引人了一种潜在的令人厌恶的运行时错误,叫做死锁(deadlock) ,它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。

  • 程序员使用 P 和 V操作顺序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹 线碰巧到达了死锁状态 d,那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了 每个合法方向上的进展。换句话说,程序死锁是因为每个线程都在等待其他线程执行一个根本不可能发生的V操作。

  • 重叠的禁止区域引起了一组称为死所区域(deadlock region )的状态。如果一个轨迹线碰巧到达了一个死锁区域中的状态,那么死锁就是不可避免的了。轨迹线可以进人死锁区域, 但是它们不可能离开。

  • 死锁是一个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。

程序死锁有很多原因,要避免死锁一般而言是很困难的。然而,当使用二元信号量来实现互斥时,可以应用下面的简单而有效的规则来避免死锁:

 互斥锁加锁顺序规则:如果对于程序中每对互斥锁 (s, t), 每个同时占用 s 和 t 的线程都按照相同的顺序对它们加锁,那么这个程序就是无死锁的。

有死锁程序的进度图:

无死锁程序的进度图:

12.8 小结

一个并发程序是由在时间上重叠的一组逻辑流组成的。
三种不同的构建并发程序的机制:进程、I/O 多路复用和线程。

进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数 据,必须要有显式的 IPC 机制。事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化为状态机,用I/O 多路复用来显式地调度这些流。因为程序运行在一个单一进程中,所以在流之间共享数据速度很快而且很容易。线程是这些方法的综合。同基于进程的流一样,线程也是由内核自动调度的。同基于 I/O 多路复用的流一样,线程是运行在一个单一进程的上下文中的,因 此可以快速而方便地共享数据。

无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。提出对信号量的 P 和 V操作就是为了帮助解决这个问题。信号量操作可以用来提供对共享数据的互斥访问,也对诸如生产者一消费者程序中有限缓冲区和读者一写者系统中的共享对象这样的资源访问进行调度。

并发也引人了其他一些困难的问题。被线程调用的函数必须具有一种称为线程安全的属性。
可重入函数是线程安全函数的一个真子集,它不访问任何共享数据。可重入函数通常比不可重人函数更为有效,因为它们不需要任何同步原语。竞争和死锁是并发程序中出现的另一些困难的问题。当程序员错误地假设逻辑流该如何调度时,就会发生竞争。当一个流等待一个永远不会发生的事件时,就会产生死锁。
四类线程不安全的函数。

posted @ 2015-12-03 18:24  20135321余佳源  阅读(258)  评论(0编辑  收藏  举报