首页 > 技术文章 > 20135323符运锦-----信息安全系统设计基础第十三周学习总结

20135323fuyunjin 2015-12-06 14:17 原文

第十三周:

学习计时:共10小时

读书:6

代码:1

作业:1

博客:2

第 11 章 网络编程

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

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

  1. 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。

  2. 服务器收到请求后,解释它,并以适当的方式操作它的资源。

  3. 服务器给客户端发送一个响应,并等待下一个请求。

  4. 客户端收到响应并处理它。

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

11.2 网络##

客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。

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

网桥比集线器更充分地利用了电缆带宽。
多个不兼容的局域网可以通过叫做路由器 (router)的特殊计算机连接起来,组成一个internet(互联网络)。

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

11.3 全球 IP 因特网##

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

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

11.3.1 IP地址

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

htonl 函数将32 位整数由主机字节顺序转换为网络字节顺序。

ntohl 函数将 32 位整数从网络宇节顺序转换为主机字节。

htons和 ntohs 函数为 16 位的整数执行相应的转换。

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

"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 数据库中检索任意的主机条目。

  • 最简单的情况下,一个域名和一个 IP 地址之间是一一映射的

  • 某些情况下,多个域名可以映射为同一个 IP 地址

  • 最通常的情况下,多个域名可以映射到多个 IP 地址

  • 某些合法的域名没有映射到任何 IP 地址

11.3.3 因特网连接

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

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

11.4 套接字接口

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

套接字接口描述:

11.4.2 socket 函数

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

11.4.3 connect 函数

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

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 函数将 sockfd 从一个主动套接字转化为一个监听套接字 (listening socket),该套接字可以接受来自客户端的连接请求。

11.4.7 open_listenfd 函数

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

11.4.8 accept 函数

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

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

监听描述符是作为客户端连接请求的一个端点。

11.5 Web 服务器

11.5.1 Web 基础

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

11.5.2 Web 内容

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

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。

2.HTTP 晌应

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

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

第 12 章 并发编程#

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

应用级并发是很有用的:

  • 访问慢速I/O设备。

  • 与人交互。

  • 通过推迟工作以降低延迟。

  • 服务多个网络客户端。

  • 在多核机器上进行并行计算。

使用应用级并发的应用程序称为并发程序 (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 处理程序必须准备好回收多个僵死子进程的资源。
  1. 父子进程必须关闭它们各自的 connfd 拷贝。这对父进程而言尤为重要,它必须关闭它的已连接描述 符,以避免存储器泄漏。
  2. 因为套接字的文件表表项中的引用计数,直到父子进程的 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 程序中变量根据它们的存储类型被映射到虚拟存储器:

全局变量。

本地自动变量。

本地静态变量。

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 操作。

信号量的函数:

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: 同步对有限缓冲区并发访问的包:

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 的线程都按照相同的顺序对它们加锁,那么这个程序就是无死锁的。

有死锁程序的进度图:

无死锁程序的进度图:

学习体会

本章学习包括11、12章的相关内容,但是因为学习较为充分且知识相对难度不是最大,所以学习过程较为顺利。

参考资料

教材:第十一、十二章,详细学习指导:http://group.cnblogs.com/topic/73069.html

课程资料:https://www.shiyanlou.com/courses/413 实验十,课程邀请码:W7FQKW4Y

教材中代码运行、思考一下,读代码的学习方法:http://www.cnblogs.com/rocedu/p/4837092.html。

推荐阅读