第11章 网络编程
网络应用随处可见。有趣的是,所有的网络应用都是基于相同的基本编程模型有着相似的整体逻辑结构,并且依赖相同的编程接口。
网络应用依赖于很多在系统研究中已经学习过的概念,例如,进程、信号、字节器映射以及动态存储分配,都扮演着重要的角色。
11.1 客户端-服务器编程模型
每个网络应用都是基于客户端-服务器模型的。一个应用是由一个服务器户端提供某种服务。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。
客户端-服务器模型中的基本操作是事务。
事务由四步组成:
- 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。
- 服务器收到请求后,解释它,并以适当的方式操作它的资源。
- 服务器给客户端发送一响应,并等待下一个请求。
- 客户端收到响应并处理它。
11.2 网络
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。
对于一个主机而言,网络只是又一种I/O设备,作为数据源和数据接收方。一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和存储器总线拷贝到存储器,典型地是通过DMA传送。
一个以太网段,包括电缆(通常是双绞线)和一个叫做集线器的小盒子。每根电缆都有相同的最大位带宽。集线器不加分辩地将一个端口上收到的每个位复制到其他所有的端口上。因此,每台主机都能看到每个位。
每个以太网适配器都有—个全球唯一的48位地址,它存储在这个适配器的非易失性存储器上。一台主机可以发送一段位,称为帧。每个主机适配器都能看到这个帧,但是只有目的主机实际读取它。
桥接以太网是使用一些电缆和叫做网桥的小盒子,将多个以太网段连接起来形成的较大的局域网。电缆的带宽可以是不同的。
网桥比集线器更充分地利用了电缆带宽。
在层次的更高级别中,多个不兼容的局域网可以通过叫做路由器的计算机连接起来,组成一个Internet。
解决办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差异。
协议必须提供两种基本能力:
- 命名机制。不同的局域网技术有不同的和不兼容的方式来为主机分配地址。
- 传送机制。在电缆上编码位和将这些位封装成帧方面,不同的联网技术有不同和不兼容的方式。一个包是由包头和有效载荷组成,其中包头包括包的大小以及源主机和目的主机的地址,有效载荷包括从源主机发出的数据位。
互联网思想的精髓,封装是关键。
11.3 全球IP因特网
全球IP因特网是最著名和最成功的互联网络实现。每台因特网主机都运行实现TCP/TP协议的软件,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合使用套接字接口函数和Unix I/O函数来进行通信。套接字函数典型地是作为会陷入内核的系统调用来实现的,并调用各种内核模式的TCP/IP函数。
把因特网看做一个世界范围的主机集合,满足一下特性:
- 主机集合被映射为一组32位的IP地址。
- 这组IP地址被映射为一组称为因特网域名的标识符。
- 因特网主机上的进程能够通过连接和任何其他因特网主机上的进程通信。
11.3.1 IP地址
一个IP地址就是一个32位无符号整数。
因为因特网主机可以有不同的主机字节顺序,TCP/IP为任意整数数据项定义了统一的网络字节顺序,它放在包头中跨过网络被携带。
Unⅸ提供了下面这样的函数在网络和主机字节顺序间实现转换:
11.3.2 因特网域名
因特网客户端和服务器互相通信时使用的是IP地址。因特网也定义了一组更加人性化的域名,以及一种将域名映射到IP地址的机制。
域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。层次结构可以表示为一棵树。树的节点表示城名,反向到根的路径形成了域名。子树称为子域。层次结构中的第一层是个未命名的根节点。下一层是一组一级域名。常见的第一层域名包括com、edu、gov、org、net。下一层是二级域名,一旦一个组织得到了一个二级域名,那么它就可以在这个子域中创建任何新的域名了。
11.3.3 因特网连接
因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度来说,它是全双工的。并且从由源进程发出的字节流最终被目的进程以它发出的顺序收到它的角度来说,它也是可靠的。
一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16位的整数端口组成的,用“地址:端口”来表示。当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口。然而,服务器套接字地址中的端口通常是某个知名的端口,是和这个服务相对应的。
一个连接是由它两端的套接字地址唯一确定的。这对套接字地址叫做套接字对。
11.4 套接字接口
套接字接口是一组函数,它们和Unix I/O函数结合起来,用以创建网络应用。
11.4.1 套接字地址结
从Unix内核的角度来看,一个套接字就是通信的一个端点。
11.4.2 socket函数
客户端和服务器使用Socket函数客户端和服务器使用函数来创建一个套接字描述符。
其中,AF_INET表明我们正在使用因特网,而SCKET_STREAM表示这个套接字是因特网连接的一个端点。
11.4.3 connect函数
客户端通过connect函数来建立和服务器的连接。
connect函数试图与套接字地址为serv_addr的服务器建立一个因特网连接,其中addrlen是size of ( sockaddr_in )。
11.4.4 open_clientfd函数
open_clientfd的代码
11.4.5 bind函数
11.4.6 listen函数
listen函数将sockfd从一个主动套接字转化为一个监听套接字。该套接字可以接受来自客户端的连接请求。
11.4.7 open_listenfd函数
11.4.8 accept函数
11.5 web服务器
11.5.1 web基础
Web客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做HTTP。
HTTP是一个简单的协议。一个web客户端(即浏览器)打开一个到服务器的因特网连接。
主要的区别是Web内容可以用HTML来编写。一个HTML程序(页)包含指令(标记)它们告诉浏览器如何显示这页中的各种文本和图形对象。
11.5.2 web内容
Web服务器以两种不同的方式向客户端提供内容:
- 取一个磁盘文件,并将它的内容返回给客户端。
- 运行一个可执行文件,并将它的输出返回给客户端。
关于服务器如何解释一个URL的后缀,以下几点需要理解:
- 确定一个URL指向的是静态内容还是动态内容没有标准的规则。
- 后缀中的最开始的那个“/”不表示Unix的根目录。
- 最小的URL后缀是“/”字符,所有服务器将其扩展为某个默认的主页。
11.5.3 http事务
一个http请求的组成是这样的:一个请求行,后面跟随零个或更多个请求报头,再跟随一个空的文本行来终止报头列表。
一个http响应的组成是这样的:一个响应行,后面跟随零个或更多个响应报头,再跟随一个终止报头的空行,再跟随一个响应主体。
11.5.4 服务动态内容
1.客户端如何将程序参数传递给服务器
2.服务器如何将参数传递给子进程
3.服务器如何将其他信息传递给子进程
4.子进程将它的输出发送到哪里
11.6 综合:tiny web服务器
- TINY的main程序
- doit函数
- clienterror函数
- read_requestthdrs函数
- parse_uri函数
- serve_static函数
- serve_dynamic函数
第12章 并发编程
到目前为止,我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机制。但是,并发不仅仅局限于内核。它也可以在应用程序中扮演重要角色。或者程序访问虚拟存储器的个未定义的区域。
应用级并发在其他情况下也是很有用的:
- 访问慢速I/O设备。
- 与人交互。
- 通过推迟工作以降低延迟。
- 服务多个网络客户端。
- 在多核机器上进行并行运算。
使用应用级并发的应用程序称为并发程序。现代操作系统提供了三种基本的构造并发程序的方法:
- 进程。
- I/O多路复用。
- 线程。
12.1 基于进程的并发编程
12.1.1 基于进程的并发服务器
一个基于进程的并发的echo服务器的代码,重要说明:
- 首先,通常服务器会运行很长时间,所以我们必须包括一个SIGCHLD处理程序,来回收僵死子进程资源。
- 其次,父子进程必须关闭他们的connfd拷贝。
- 最后,因为套接字的文件表表项的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。
12.1.2 关于进程的优劣
关于进程的优劣,对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点又是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟存储器。另一方面,独立的地址空间使得进程共享状态信息变得更加困难。
12.2 基于i/o多路复用的并发编程
假设要求你编写一个echo服务器,它也能对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个相互独立的I/O事件:1)网络客户端发起连接请求,2)用户在键盘上键入命令行。如果accept中等待每一个连接请求,那么无法响应输入命令。如果在read中等待一个输入命令,我们就不能响应任何连接请求。
针对这种困境的一个解决办法就是I/O多路复用技术。基本的思路就是使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生后,才将控制返给应用程序。
12.2.1 基于i/o多路复用的并发事件驱动服务器
I/O多路复用可以用做并发事件驱动程序的基础,在事件驱动程序中,流是因为某种事件而前进的。一般概念是将逻辑流模型化为状态机,不严格地说,一个状态机就是一组状态,输入事件和转移,其中转移就是将状态和输入事件映射到状态,每个转移都将一个(输入状态,输入事件)对映射到一个输出状态,自循环是同一输入和输出状态之间的转移,通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输人事件,一个状态机从某种初始状态开始执行,每个输入事件都会引发一个从当前状态到下一状态的转移。
12.2.2 i/o多路复用技术的优劣
事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。另一个优点是,一个基于I/O多路复用的事件驱动器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。最后,事件驱动设计常常比基于进利的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。
事件驱动设计的一个明显的缺点就是编码复杂,我们的事件驱动的并发echo服务器需要的代码比基于进程的代码多三倍。基于事件的设计的另一个重大缺点是它们不能充分利利用多核处理器。
12.3 基于线程的并发编程
线程就是运行在进程上下文中的逻辑流。每个线程都有自己的线程上下文,包括一个线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。多个线程运行在单一进程的上下文中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
12.3.1 线程执行模型
多线程的执行模型在某些方面和多进程的执行模型是相似的。每个进程开始生命周期时都是单一线程,这个进程称为主线程。在某一时刻,主线程创建一个对等线程,从这个时间点开始,两个线程就并发地运行。
12.3.2 Posix线程
Posix线程是在C程序中处理线程的一个标准接口。Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
12.3.3 创建线程
线程通过调用pthread_create函数来创建其他进程。
12.3.4 终止线程
一个线程是以下列方式之一来终止的:
- 当顶层的线程例程返回时,线程会隐式地终止。
- 通过调用pthread_exit函数,线程会显它会等待所有其他对等线程终止,然后再终止式地终止。
- 某个对等线程调用Unix的exit函数,该函数终止进程以及所有与该进程相关的线程。
- 另一个对等线程通过以当前线程ID作为参数调用pthread_cancle函数来终止进程。
12.3.5 回收已终止线程的资源
12.3.6 分离线程
12.3.7 初始化线程
12.4 多线程程序中的共享变量
为了理解C程序中的一个变量是否是共享的,有一些基本的问题要解答:1)线程的基础存储器模型是什么?2)根据这个模型,变量实例是如何映射到存储器的?3)最后,有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。
12.4.1 线程存储器模型
一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本代码、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。
各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。
12.4.2 将变量映射到存储器
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
- 全局变量。全局变量是定义在函数之外的变量。
- 本地自动变量。本地自动变量就是定义在函数内部但是没有static属性的变量。
- 本地静态变量。本地静态变量是定义在函数内部并有static属性的变量。
12.4.3 共享变量
我们说一个变量V是共享的,当且仅当它的一个实例被一个以上的线程引用。
12.5 用信号量同步线程
将线程i的循环代码分解成五个部分:
一般而言,你没有办法预测操作系统是否将为你的线程选择一个正确的顺序。
12.5.1 进度图
进程图将n个并发进程的执行模型化为一条n维笛卡尔空间中的轨迹线。
12.5.2 信号量
信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V。
- P(s):如果s是非零的,那么P将s减1并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个v操作会重启这个线程。
- V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。
当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个进程。
12.5.3 使用信号量来实现互斥
信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量与一个信号量s(初始为1)联系起来 ,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或者1。以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁。类似地,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器的信号量称为计数信号量。
关键思想是这种P和V操作的结合创建了一组状态,叫做禁止区。
12.5.4 利用信号量来调度共享资源
12.5.5 综合:基于预线程化的并发服务器
我们为每一个新客户端创建了一个新线程这种方法的缺点是我们为每一个新客户端创建一个新线程,导致不小的代价。
服务器是由一个主线程和一组工作者线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个不限缓冲区中。每一个工作者线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。
12.6 使用线程提高并行性
在对并发的研究中,我们都假设并发线程是在单处许多现代机器具有多核处理器。并发程序通常在这样的机器上运理器系统上执行的。然而,在多个核上并行地调度这些并发线程,而不是在单个核顺序地调度。
12.7 其他并发问题
12.7.1 线程安全
一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,我们就说它是线程不安全的。
定义出四个线程不安全函数类:
- 不保护共享变量的函数。利用P和V操作这样的同步操作来保护共享的变量。
- 保持跨越多个调用的状态函数。
- 返回指向静态变量的指针的函数。
- 调用线程不安全函数的函数。
12.7.2 可重入性
有一类重要的线程安全函数,叫做可重入函数,其特点在于他们具有这样一种属性:当它们被多个线程调用时,不会引用任何共享数据。
可重入函数通常要比不可重入的线程安全的函数高效一些,因为它们不需要同步操作。更进一步来说,将第2类线程不安全函数转化为线程安全函数的唯一方法就是重写它,使之变为可重入的。
12.7.3 在线程化的程序中使用已存在的库函数
12.7.4 竞争
当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的X点时,就会发生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹线穿过执行状态空间,而忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。
12.7.5 死锁
信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁。它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。
关于死锁的重要知识:
- 程序员使用P和V操作漏序不当,以至于两个信号量的禁止区域重叠。
- 重叠的禁止区域引起了一组称为死锁区域的状态。
- 死锁是个相当困难的问题,因为它不总是可预测的。
简单而有效的规则来避免死锁:
互斥锁加锁顺序规则:如果对于程序中每对互斥锁(s,t),给所有的锁分配一个全序,每个线程按照这个顺序来请求锁,并且按照逆序来释放,那么这个程序就是无死锁的。
参考内容:课本