1.网络应用随处可见。任何时候你浏览Web、发送Email或者弹出一个X window,你就正在使用一个网络应用程序。有趣的是,所有的网络应用都是基于相同的基本编程模型有着相似的整体逻辑结构,并且依赖相同的编程接口。
2.网络应用依赖于很多在系统研究中已经学习过的概念,例如,进程、信号、字节器映射以及动态存储分配,都扮演着重要的角色。还有一些新概念要掌握。我们需要理解基本的客户端-服务器编程模型,以及如何编写使用因特网提供的服务的客户端―服务器程序。最后,我们将把所有这些概念结合起来,开发一个小的但功能齐全的Web的服务器,能够为真实的Web,浏览器提供静态和动态的文本和图形内容。
1.每个网络应用都是基于客户端-服务器模型的。采用这个模型,一个应用是由一个服务器户端提供某种服务。服务器管理某种资源,并且通过操作这种资源来为它的客户端提供某种服务。—个FTP服务器就管理了一组磁盘文件,它为客户端进行它会为客户端进行存储和检索。相似地一个电子邮件服务器管理了一些文件,它为客户端进行读和更新。
2.客户端-服务器模型中的基本操作是事务
3.事务由四步组成
1)当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。例如,当Web览器需要一个文件时,它就发送一个请求给Web服务器
2)服务器收到请求后,解释它,并以适当的方式操作它的资源。例如,当Web服务器收到浏览器发出的请求后,它就读一个磁盘文件
3)服务器给客户端发送一响应,并等待下一个请求。例如,Web服务器将文件发送回客户端;
4)客户端收到响应并处理它。例如,当Web浏览器收到来自服务器的一页后,它就在屏幕上显示此页。
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。网络是复杂的系统,在这里我们只想了解一点皮毛。我们的目标是从程序员的角度给你一个可工作的思考模型。对于一个主机而言,网络只是又一种I/O设备,作为数据源和数据接收方,如图所示。一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和存储器总线拷贝到存储器,典型地是通过DMA(译者注:直接存储器存取方式)传送。相似地,数据也能从存储器拷贝到网络。
1.一个以太网段,包括电缆和集线器;每根电缆都有相同的最大位带宽;集线器不加分辩地将一个端口上收到的每个位复制到其他所有的端口上。因此,每台主机都能看到每个位。
2.每个以太网适配器都有—个全球唯一的48位地址,它存储在这个适配器的非易失性存储器上。每个主机适配器都能看到这个帧,但是只有目的主机实际读取它。
3.桥接以太网 由 电缆和网桥 将多个以太网段连接起来,形成的较大的局域网。连接网桥的电缆传输速率可以不同(例:网桥与网桥之间1GB/S, 网桥与集线器之间100MB/S)。
4.网桥作用:连接不同网段。同一网段内A向B传输数据时,帧到达网桥输入端口,网桥将其丢弃,不予转发。A向另一网段内C传输数据时,网桥才将帧拷贝到与相应网段连接的端口上。从而节省了网段的带宽
(1)协议软件的基本能力:
命名机制 为每台主机至少分配一个互联网地址,从而消除不同主机地址格式的差异,是每台主机能被识别。
传送机制 不同格式的数据进行封装,使其具有相同的格式。
全球IP因特网是最著名和最成功的互联网络实现。从1969年起,它就以这样或那样的形式存在了。虽然因特网的内部体系结构复杂而且不断变化,但是自从20世纪80年代早期以来,客户端-服务器应用的组织就一直保持相当的稳定。下图展示了一个因特网客户端-服务器应用程序的基本硬件和软件组织。每台因特网主机都运行实现TCP/TP协议的软件,几乎每个现代计算机系统都支持这个协议。因特网的客户端和服务器混合使用套接字接口函数和Unix I/O函数来进行通信。套接字函数典型地是作为会陷入内核的系统调用来实现的,并调用各种内核模式的TCP/IP函数。、
1.一个IP地址就是一个32位无符号整数。
2.网络程序将IP地址存放在下图所示的IP地址结构中。
1.因特网客户端和服务器互相通信时使用的是IP地址。然而,对于人们而言,大整数是很难记住的,所以因特网也定义了一组更加人性化的域名,以及一种将域名映射到IP地址的机制。域名是一串用句点分隔的单词(字母、数字和破折号)。
2.域名集合形成了一个层次结构,每个域名编码了它在这个层次中的位置。通过一个示例你将很容易理解这点。下展示了域名层次结构的一部分。层次结构可以表示为一棵树。树的节点表示城名,反向到根的路径形成了域名。子树称为子域。层次结构中的第一层是个未命名的根节点。下一层是一组一级域名由非赢利组织(因特网分酒名字数字协会)定义。常见的第一层域名包括com、edu、gov、org、net,这些域名是由ICANN的各个授权代理按照先到先服务的基础分配的的。一旦一个组织得到了一个二级域名,那么它就可以在这个子域中创建任何新的域名了。
1.因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度来说,它是全双工的。并且从(除了一些如粗心的耕锄机操作员切断了电缆引起灾对性的失败以外)由源进程发出的字节流最终被目的进程以它发出的顺序收到它的角度来说,它也是可靠的。
2.一个套接字是连接的一个端点。每个套接字都有相应的套接字地址,是由一个因特网地址和一个16位的整数端口组成的,用“地址:端口”来表示。当客户端发起一个连接请求时,客户端套接字地址中的端口是由内核自动分配的,称为临时端口。然而,服务器套接字地址中的端口通常是某个知名的端口,是和这个服务相对应的。例如,web服务器通常使用端口80,电子邮件服务器使用端口25。
从Unix内核的角度来看,一个套接字就是通信的一个端点。
Socket函数客户端和服务器使用函数来创建一个套接字描述符.
其中,AF_INET表明我们正在使用因特网,而SCKET_STREAM表示这个套接字是因特网连接一个端点。Socket返回的clientfd描述符仅是部分打开的,还不能用于读写。如何完成打开套接字的工作,取决于我们是客户端还是服务器。
客户端通过connect函数来建立和服务器的连接。
将socket和connect包装
bind函数
listen函数将sockfd从一个主动套接字转化为一个监听套接字。该套接字可以接受来自客户端的连接请求。backlog参数暗示了内核在开始拒绝连接请求之前,该放入队列中等待的未完成连接请求的数量
1.Web客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做HTTP。
2.HTTP是一个简单的协议。一个web客户端(即浏览器)打开一个到服务器的因特网连接。浏览器读取这些内容,并请求某些内容。服务器响应所请求的内容,然后关闭连接。浏览器读取并把它显示在屏幕内
3.主要的区别是Web内容可以用HTML来编写。一个HTML程序(页)包含指令(标记)它们告诉浏览器如何显示这页中的各种文本和图形对象。
Web服务器以两种不同的方式向客户端提供内容:
1.取一个磁盘文件,并将它的内容返回给客户端。
2.运行一个可执行文件,并将它的输出返回给客户端。
1.http请求
2.http响应
1.客户端如何将程序参数传递给服务器
2.服务器如何将参数传递给子进程
3.服务器如何将其他信息传递给子进程
4.子进程将它的输出发送到哪里
到目前为止,我们主要将并发看做是一种操作系统内核用来运行多个应用程序的机制。但是,并发不仅仅局限于内核。它也可以在应用程序中扮演重要角色。例如,我们已经看到Unix信号处理程序如何允许应用响应异步事件,例如用户键入。或者程序访问虚拟存储器的个未定义的区域.应用级并发在其他情况下也是很有用的,例如:
1.访问慢速I/O设备:当一个应用正在等待来自慢速I/O设备(例如磁盘)的数据到达时,内I/O请求和其他有用的工作来使用并发。
2.与人交互。和计算机交互的人要求计算机有同时执行多个任务的能力,例如,他们在打印个文档时,可能想要调整一个窗口的大小。现代视窗系统利用并发来提供这种能力,每次用户请求某种操作(比如通过单击鼠标时,一个独立的并发逻辑流被创建来执行这个操作。
3.���过推迟工作以降低延迟。有时,应用程序能够通过推迟其他操作和并发地执行它们利用并发来降低某些操作的延迟。比如,一个动态存储分配器可以通过推迟合并.把它放到个运行在较低优先级上的并发“合并”流中在有空闲的CPU周期时充分利用这些空闲周期从而降低单个free操作的延迟
4.服务多个网络客户端。我们在第U章中学习的迭代网络服务器是不现实的,因为它们次只能为一个客户端提供服务。因此一个慢速的客户端可能会导致服务器拒绝为所有其他客户端服务。对于一个真正的服务器来说,可能期望它每秒为成百上千的客户端提供服务,由于一个慢速客户端导致拒绝为其他客户端服务,这是不能接受的一个更好的方法是创建一个并发服务器,它为每个客户端创建一个单独的逻辑流。这就允许服务器同时为多个客户端服务并且这也避免了慢速客户端独占服务器。
5.在多核机器上进行并行计算 多核处理器,多核处理器中包含多个CPU。被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行理器中包含多个这些流会并行执行,而不是交错执行
6.现代操作系统提供了三基本的构造并发程序的方法:进程、I/O多路复用、线程。
1.在接受连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整拷贝。子进程关闭它的拷贝中的监听描述符3,而父进程关闭它的已连接描述符4的拷贝,因为不再需要这些描述符了。这就得到了图中的状态,其中子进程正忙于为客户端提供服务。因为父子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的拷贝是至关重要的。否则,将永远不会释放已连接描述符4的文件表条目,而且由此引起的存储器泄漏将最终消耗尽可用的存储器,使系统崩溃。
2.现在假设在父进程为客户端1创建了子进程之后,它接受一个新的客户端2的连接请求,并返回一个新的已连接描述符(比如描述符5)如图所示。然后,父进程又派生另一个子进程,这个子进程用已连接描述符5为它的客户端提供服务,如图所示。此时,父进程正在等待下一个连接请求,而两个子进程正在形地为它们各自的客户端提供服务。
首先,通常服务器会运行很长时间,所以我们必须包括一个SIGCHLD处理程序,来回收僵死子进程资源。
其次,父子进程必须关闭他们的connfd拷贝。
最后,因为套接字的文件表表项的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。
关于进程的优劣,对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址控件爱你既是优点又是缺点。由于独立的地址空间,所以进程不会覆盖另一个进程的虚拟存储器。但是另一方面进程间通信就比较麻烦,至少开销很高。
1.比如一个服务器,它有两个I/O事件:1)网络客户端发起连接请求,2)用户在键盘上键入命令行。我们先等待那个事件呢?没有那个选择是理想的。如果accept中等待连接,那么无法相应输入命令。如果在read中等待一个输入命令,我们就不能响应任何连接请求(这个前提是一个进程)。
2.针对这种困境的一个解决办法就是I/O多路复用技术。基本思想是:使用select函数,要求内核挂起进程,只有在一个或者多个I/O事件发生后,才将控制返给应用程序。如图所示:横向的方格可以看作是一个n位的描述符向量。现在,我们定义第0位描述是“标准输入”,第3位描述符是“监听描述符”。
I/O多路复用可以用做并发事件驱动程序的基础,在事件驱动程序中,流是因为某种事件而前进的,一般概念是将逻辑流模型化为状态机,不严格地说,一个状态机就是一组状态,输入事件和转移,其中转移就是将状态和输入事件映射到状态,每个转移都将一个(输入状态,输入事件)对映射到一个输出状态,自循环是同一输入和输出状态之间的转移,通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输人事件,一个状态机从某种初始状态开始执行,每个输入事件都会引发一个从当前状态到下一状态的转移,对于每个新的客户端k,基于I/O多路复用的并发服务器会创建一个新的状态机S,并将它和已连接描述符d联系起来。
1.事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。例如我们可以设想编写一个事件驱动的并发服务器,为某些客户提供他们需要的服务,而这对于新进程的并发服务器来说,是很困难的
2.另一个优点是,一个基于I/O多路复用的事件驱动器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易,一个与作为单个进程运行相关的优点是,你可以利用熟悉的调试工具,例如GDB,来调试你的并发服务器,就像对顺序程序那样。最后,事件驱动设计常常比基于进利的设计要高效得多,因为它们不需要进程上下文切换来调度新的流。
3.事件驱动设计的一个明显的缺点就是编码复杂,我们的事件驱动的并发服务器需要的代度是指每个逻辑流每个时间片执行的指令数量。基于事件的设计的另一个重大缺点是它们不能充分利利用多核处理器。
每个线程都有自己的线程上下文,包括一个线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有的运行在一个进程里的线程共享该进程的整个虚拟地址空间。由于线程运行在单一进程中,因此共享这个进程虚拟地址空间的整个内容,包括它的代码、数据、堆、共享库和打开的文件。
线程执行的模型。线程和进程的执行模型有些相似。每个进程的声明周期都是一个线程,我们称之为主线程。但是大家要有意识:线程是对等的,主线程跟其他线程的区别就是它先执行。
POSIX线程是在C程序中处理线程的一个标准接口。它最早出现在1995年,而且在大多数Unix系统上都可用。Pthreads定义了大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。
线程通过调用pthread_create函数来创建其他进程。
一个线程是以下列方式之一来终止的。
当顶层的线程例程返回时,线程会隐式地终止
通过调用pthread_exit函数,线程会显它会等待所有其他对等线程终止,然后再终止式地终止。
某个对等线程调用Unix的e×it函数,该函数终止进程以及所有与该进程相关的线程
全局变量和static 变量 是存储在数据段,所以,多线程共享之!
由于线程的栈是独立的,所有线程中的自动变量是独立的。即使多个线程运行同一段代码总的自动变量,那么他们的值也是根据线程的不同而不同。
比如C++中,类属性不是在用户栈中的。所以线程共享之!
1.一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本代码、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享同样的打开文件的集合。
2.从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟存储器的任意位置。如果某个线程修改了一个存储器位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟存储器总是共享的。
3.各自独立的线程栈的存储器模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的所以,如果个线程以某种方式得到个指向其他线程栈的指慧:那么它就可以读写这个栈的任何部分。
线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:
1.全局变量。全局变量是定义在函数之外的变量,在运行时,虚拟存储器的读/写区域域只包含每个全局变量的一个实例,任何线程都可以引用。例如第5行声明的全局变量ptr在虚拟存储器的读/写区域中有个运行时实例,我们只用变量名(在这里就是ptr)来表示这个实例。
2.本地自动变量,本地自动变量就是定义在函数内部但是没有static属性的变量,在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使当多个线程执行同一个线程例程时也是如此。例如,有个本地变量tid的实例,它保存在主线程的栈中。我们用tid.m来表示这个实例
3.本地静态变量
我们说一个变量V是共享的,当且仅当它的一个实例被一个以上的线程引用。例如,示例程序中的变量cnt就是共享的,因为它只有一个运行时实例,并且这个实例被两个对等线程引用在另一方面,myid不是共享的,因为它的两个实例中每一个都只被一个线程引用。然而,认识到像msgs这样的本地自动变量也能被共享是很重要的。
信号量通常称之为PV操作,虽然它的思想是将临界代码保护起来,达到互斥效果。这里面操作系统使用到了线程挂起。
将线程i的循环代码分解成五个部分:
进程图将n个并发进程的执行模型化为一条n维笛卡尔空间中的轨迹线。
信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V
P(s):如果s是非零的,那么P将s减1并且立即返回。如果s为零,那么就挂起这个线程,直到s变为非零,而一个y操作会重启这个线程。在重启之后,P操作将s减1并将控制返回给调用者
V(s):V操作将s加1。如果有任何线程阻塞在P操作等待s变成非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作,P中的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非零,就会将s减1,不能有中断。V中的加1操作也是不可分割的,也就是加载、加和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重新启动的顺序。唯—的要求是V必须只能重启一个正在等待的进程。
信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量联系起来 。以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或者1。以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁。类似地,执行V操作称为对互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器的信号量称为计数信号量。关键思想是这种P和V操作的结合创建了一组状态,叫做禁止区。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所以没有实际可行的轨迹线能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器的值。
生产者-消费者问题。
在如图所示的并发服务器中,我们为每一个新客户端创建了一个新线程这种方法的缺点是我们为每一个新客户端创建一个新线程,导致不小的代价。一个基于预线程化的服务器试图通过使用如图所示的生产者-消费者模型来降低这种开销。服务器是由一个主线程和一组工作者线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的连接描述符放在一个不限缓冲区中。每一个工作者线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。
到目前为止,在对并发的研究中,我们都假设并发线程是在单处许多现代机器具有多核处理器。并发程序通常在这样的机器上运理器系统上执行的。然而,在多个核上并行地调度这些并发线程,而不是在单个核顺序地调度,在像繁忙的Web服务器、数据库服务器和大型科学计算代码这样的应用中利用这种并行性是至关重要的。
不保护共享变量的函数。解决办法是PV操作。
保持跨越多个调用的状态函数。比如使用静态变量的函数。解决方法是不要使用静态变量或者使用可读静态变量。
返回指向静态变量的指针的函数。解决方法是lock-and-copy(枷锁-拷贝)
调用线程不安全函数的函数
死锁。
当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的X点时,就会发生竞争。通常发生竞争是因为程序员假定线程将按照某种特殊的轨迹正确工作忘记了另一条准则规定:线程化的程序必须对任何可行的轨迹线都正确工作。
1.信号量引入了一种潜在的令人厌恶的运行时错误,叫做死锁。它指的是一组线程被阻塞了,等待一个永远也不会为真的条件。进度图对于理解死锁是一个无价的工具。
2.关于死锁的重要知识:
3.程序员使用P和V操作漏序不当,以至于两个信号量的禁止区域重叠。如果某个执行轨迹线碰巧到达了死锁状态d那么就不可能有进一步的进展了,因为重叠的禁止区域阻塞了每个合法方向上的进展。换句话说,程序死锁是因为每个线程在等待一个根本不可能发生的V操作
4.重叠的禁止区域引起了一组称为死锁区域的状态。轨迹线可以进入死锁区域,但是它们不可能离开。
5.死锁是个相当困难的问题,因为它不总是可预测的。一些幸运的执行轨迹线将绕开死锁区域,而其他的将会陷入这个区域。
1.教材:第十一、十二章,详细学习指导:http://group.cnblogs.com/topic/73069.html
2.课程资料:https://www.shiyanlou.com/courses/413 实验十,课程邀请码:W7FQKW4Y
3.教材中代码运行、思考一下,读代码的学习方法:http://www.cnblogs.com/rocedu/p/4837092.html。
4.内容总结:http://www.tuicool.com/articles/qAZnea
遇到的问题
1.线程跟进程以及i/o复用相比好处
2.socket函数有些代码不能完全理解,比如MAKEWORD(1,1)是什么意思