计算机通信

利用Unix操作系统提供的I/O服务来构建应用程序,应用程序利用操作系统提供的服务I/O设备及其他程序通信

输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备

所有语音的运行时系统都提供执行I/O的较高级别的工具。在Linux系统中,是通过使用内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的

有了高级别的I/O函数,还需要使用Unix I/O。比如,标准I/O库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O库还存在一些问题,使得它来进行网络编程非常冒险

一个Linux文件就是一个m个字节的序列。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行

一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备(socket也相当于一个设备,也有对应的文件,存在内存中)。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符(Linux创建每个进程时,都有三个打开的文件)

对于每个打开的文件,内核保持着一个文件位置k,初始为0。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n

当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符

套接字是用来与另一个进程进行跨网络通信的文件

如果打开的文件对应于网络套接字,那么内部缓冲约束和较长的网络延迟会引起read和write返回不足值,如果想创建健壮的诸如Web服务器这样的网络应用就必须通过反复调用read和write处理不足值,直到所有需要的字节传送完毕

RIO(Robust I/O,健壮的I/O)包提供了两类函数:

  1. 无缓冲的输入输出函数(这些函数直接在内存和文件之间传送数据,没有应用级缓冲。它们对将二进制读写到网络和从网络读写二进制数据尤其有用)
  2. 带缓冲的输入函数(这些允许高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内。带缓冲的RIO输入函数是线程安全的

每个进程·都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的

打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数,以及一个指向v-node表中对于表项的指针

v-node表,所有的进程共享。每个表项包含stat结构中的大多数信息,包括st_mode和st_size成员

父进程调用了fork后,子进程有一个父进程描述符表的副本,父子进程共享相同的打开文件表集合。共享相同的文件位置

Linux shell提供了I/O重定向操作符允许用户将磁盘文件和标准输入输出联系起来。当一个Web服务器代表客户端运行CGI程序时,它就执行一种相似类型的重定向

dup2函数复制描述符表表项oldfd到描述符表表项newfd,覆盖描述符表表项newfd以前的内容

缓冲区的目的就是使开销较高的Linux I/O系统调用的数量尽可能小

Linux对网络的抽象是一种称为套接字的文件类型。应用程序进程通过读写套接字描述符运行在其他计算机的进程实现通信

  • 对磁盘和终端设备来说,标准I/O函数是首选方法
  • 对网络套接字使用RIO函数

流、套接字的限制:

  • 限制一:跟在输出函数之后的输入函数
  • 限制二:跟在输入函数之后的输出函数
  • 对于限制一,可以通过采用在每个输入操作前刷新缓冲区来满足;限制二的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来读,一个用来写

******

每个网络应用都是基于客户端-服务器模型(客户端和服务器是进程,而不是主机。一台主机上可以同时运行许多不同的客户端和服务器)。采用这种模型,一个应用是由一个服务器进程和一个或者多个客户端进程组成的

服务器管理着某种资源,并且通过操作这种资源来为它的客户端提供某种服务

客户端-服务器模型中的基本操作事务。由以下四步组成:

  1. 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务
  2. 服务器收到请求后,解释它,并以适当的方式操作它的资源
  3. 服务器给客户端发送一个响应,并等待下一个请求
  4. 客户端收到响应并处理它。例如,当Web浏览器收到来自服务端的一页后,就在屏幕上显示此页

客户度-服务器事务不是数据库事务,没有数据库事务的任何特性,例如原子性。事务仅仅是客户端和服务器执行的一系列步骤

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

一个插到I/O总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过I/O和内存总线复制到内存。相似地,数据也能从内存复制到网络

以太网段,一端连接到主机的适配器,而另一端则连接到集线器的一个端口上

每个以太网适配器都有一个全球唯一的48位地址,它存储在这个适配器的非易失性存储器上

如何能够让某台源主机跨过所有这些不兼容的网络发送数据位到另一台目的主机呢?解决的办法是一层运行在每台主机和路由器上的协议软件,它消除了不同网络之间的差异。通过定义一种把数据位捆扎成不连续的片(称为包)的统一方式,从而消除了这些差异

  1. 运行在主机A上的客户端进行一个系统调用,从客户端的虚拟空间地址复制数据到内核缓冲区中
  2. 主机A上的协议软件通过在数据前附加互联网络包头和LAN1帧头,创建了一个LAN1的帧。互联网络包头寻址到互联网络主机B,LAN1帧头寻址到路由器。然后它传送此帧至适配器。这种封装是基本的网络互联方法之一
  3. LAN1适配器复制该帧到网络上
  4. 当此帧到达路由器时,路由器的LAN1适配器从电缆上读取它,并把它传送到协议软件
  5. 路由器从互联网包头中提取出目的互联网络地址,并把它作为路由表的索引,确定向哪里转发这个包,在本例中是LAN2。路由器剥落旧的LAN1的帧头,加上寻址到主机B的新的LAN2帧头,并把得到的帧传送到适配器
  6. 路由器的LAN2适配器复制该帧到网络上
  7. 当此帧到达主机B时,它的适配器从电缆上读到此帧,并将它传送到软件协议
  8. 最后,主机B上的协议软件剥落包头和帧头。当服务器进行一个读取这些数据的系统调用时,协议软件最终将得到的数据复制到服务器的虚拟地址空间

******

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

Linux内核的角度来看,一个套接字就是一个通信的端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件

socket返回的描述符仅是部分打开的,还不能用于读写如何完成打开套接字的工作,取决于我们是客户端还是服务器

  1. 客户端通过connect函数与服务器建立一个因特网连接。一旦连接成功,socket返回的描述符就准备好可以读写了
  2. 服务器通过bind函数告诉内核将addr中的服务器套接字地址和套接字描述符sockfd联系起来
  3. 客户端是发起连接请求的主动实体,服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为socket函数创建的描述符对应于主动套接字listen函数告诉内核,描述符是被服务器而不是客户端使用。listen函数将一个主动套接字转化为一个监听套接字,该套接字可以接受来自客户端的连接请求
    backlog参数暗示了内核在开始拒绝连接请求之前,队列中要排队的未完成的连接请求的数量
  4. accept函数等待来自客户端的连接请求到达侦听描述符listenfd,在addr中填写客户端的套接字地址,并返回一个已连接描述符,这个描述符可被用来利用Unix I/O函数与客户端通信
  5. 监听描述符是作为客户端连接请求的一个端点。它通常被创建一次,存在于服务器的整个生命周期。已连接描述符存在于服务器为一个客户端服务的过程中

  1. 第一步中,服务器调用accept,等待连接请求到达监听描述符
  2. 第二步中,客户端调用connect函数,发送一个连接请求到listenfd
  3. 第三步中,accept函数打开一个新的已连接描述符connfd,在clientfd和connfd之间建立连接,并随后返回connfd给应用程序
  4. 客户端也从connect返回,在这一点以后,客户端和服务器就可以分别通过读和写clientfd和connfd来回传送数据

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

  1. 取一个磁盘文件,并将它的内容返回给客户端。磁盘文件称为静态内容,而返回文件给客户端的过程称为服务静态内容
  2. 运行一个可执行文件,并将它的输出返回给客户端。运行时可执行产生的输出称为动态内容,而运行程序并返回它的输出到客户端的过程称为服务动态内容
  3. 每条由Web服务器返回的内容都是和它管理的某个文件相关联。这些文件中的每一个都有一个唯一的名字,叫做URL(Universal Resource Locator,通用资源定位符)

在服务器接收一个如下的请求后

GET /cgi-bin/adder?15000&213 HTTP/1.1

服务器调用fork来创建一个子进程,并调用execve在子进程的上下文中执行/cgi-bin/adder程序。像adder这样的程序,常常被称为CGI程序,因为它们遵守CGI标准的规则

调用execve之前,子进程将CGI环境变量QUERY_STRING设置为“15000&213”,adder程序在运行时可以用Linux getenv函数来引用它

一个CGI程序将它的动态内容发送到标准输出。在子进程加载并运行CGI程序之前,它使用Linux dup2函数将标准输出重定向到和客户端相关联的已连接描述符。因此,任何CGI程序写到标准输出的东西都会直接到达客户端

父进程不知道子进程生成的内容的类型或大小,所以子进程就要负责生成Content-type和Content-length响应报头,以及终止报头的空行

CGI定义了大量的其他环境变量,一个CGI程序在它运行时可以设置这些环境变量:

******

一个并发服务器,它为每个客户端创建一个单独逻辑流。这就允许服务器同时为多个客户端服务,并且也避免了慢速客户端独占服务器

被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行得快,因为这些流会并行执行,而不是交错执行

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

  1. 进程。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显示的进程间通信(interprocess communication,IPC)机制
  2. I/O多路复用。在这种形式的并发编程中,应用程序在一个进程的上下文中显示地调度它们自己的逻辑流(逻辑流被模型化为状态机,数据到达文件描述符后,主程序显示地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间)
  3. 线程。线程是运行在一个单一进程上下文中的逻辑流。可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,也像I/O多路复用流一样共享同一个虚拟地址空间

构造并发程序最简单的方法就是用进程,使用那些大家都很熟悉的函数,像fork、exec和waitpid。例如,一个构造并发服务器的自然方法就是,在父进程中接收客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务

假设我们有两个客户端和一个服务器,服务器正在监听一个监听描述符(比如描述符3)上的连接请求。现在假设服务器接收了客户端1的连接请求,并返回一个已连接描述符(比如描述符4)。在接受连接请求之后,服务器派生一个子进程,这个子进程获得服务器描述符表的完整副本子进程关闭它的副本中的监听描述符3,而父进程关闭它的已连接描述符4的副本,因为不再需要这些描述符了

父进程关闭它的已连接描述符的副本是至关重要的。否则,将永不会释放已连接描述符4的文件表条目,而且由此引发的内存泄漏将最终消耗光可用的内存,使系统崩溃

对于父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间

假设要编写一个echo服务器,它也能对用户从标准输入键入的交互命令做出响应。在这种情况下,服务器必须响应两个互相独立的I/O事件:1)网络客户端发起连接请求,2)用户在键盘上键入命令行。我们先等待哪个事件呢?没有哪个选择是理想的。针对这种困境的一个解决办法就是I/O多路复用技术。基本的思想就是使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序

select有一个副作用,它修改参数fdset指向的fd_set,指明读集合的一个子集,称为准备好集合,这个集合是由读集合中准备好可以读了的描述符组成的。该函数返回的值指明了准备好集合的基数。注意,由于这个副作用,我们必须在每次调用select时都更新读集合

select函数会一直阻塞,直到读集合中至少有一个描述符准备好可以读。当且仅当一个从该描述符读取一个字节的请求不会阻塞时,描述符k就表示准备好可以读了

I/O多路复用可以用作并发事件驱动程序的基础,在事件驱动程序中,某些事件会导致流向前推进。一般的思路是将逻辑流模型化为状态机。不严格地说,一个状态机就是一组状态、输入事件和转移每个转移是将一个(输入状态,输入事件)对映射到一个输出状态。每个输入事件都会引发一个从当前状态到下一状态的转移

对于每个新的客户端k,基于I/O多路复用的并发服务器会创建一个新的状态机sk,并将它和已连接描述符dk联系起来。如图,每个状态机sk都有一个状态(“等待描述符dk准备好可读”)、一个输入事件(“描述符dk准备好可以读了”)和一个转移(“从描述符dk读一个文本行”)

服务器使用I/O多路复用,借助select函数检测输入事件的发生。当每个已连接描述符准备好可读时,服务器就为相应的状态机执行转移,在这里就是从描述符读和写回一个文本行

  • 基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易;事件驱动设计常常比基于进程的设计要高效得多,因为他们不需要进程上下文切换来调度新的流
  • 事件驱动设计一个明显的缺点,只要某个逻辑流正忙于读一个文件行,其他逻辑流就不可能有进展

线程就是运行在进程上下文中的逻辑流

每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。线程也共享相同的打开文件的集合

从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置

每个进程开始生命周期时都是单一线程,这个线程称为主线程。在某一时刻,主线程创建一个对等线程,从这个时间点开始,两个线程就并发地运行

主线程和其他线程的区别仅在于它总是进程中第一个运行的线程

一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。另外,每个对等线程都能读写相同的共享数据

线程的上下文切换要比进程的上下文切换快得多

pthread_create函数创建一个新的线程,并带着一个输入变量arg,在新线程的上下文中运行线程例程f

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

  • 当顶层的线程例程返回时,线程会隐式地终止
  • 通过调用pthread_exit函数,线程会显示地终止。如果主线程调用pthread_exit,它会等待所有其他对等线程终止,然后再终止主线程和整个进程,返回值为thread_return
  • 某个对等线程调用Linux的exit函数,该函数终止进程以及所有与该进程有关的线程
  • 另一个对等线程通过以当前线程ID作为参数调用pthread_cancel函数来终止当前线程

pthread_join函数会阻塞,直到线程tid终止,然后回收已终止线程占用的所有内存资源

在任何一个时间点上,线程是可结合的或者分离的。一个可结合的线程能够被其他线程收回和杀死。在被其他线程回收之前,它的内存资源(例如栈)是不释放的。一个分离的线程是不能被其他线程回收或杀死的。它的内存资源在它终止时由系统自动释放

默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合线程都应该要么被其他线程显示地收回,要么通过调用pthread_detach函数被分离

在现实程序中,有很好的理由要使用分离的线程。例如,一个高性能Web服务器可能在每次收到Web浏览器的连接请求时都创建一个新的对等线程。因为每个连接都是由一个单独的线程独立处理的,所以对于服务器而言,就很没有必要显示地等待每个对等线程终止。在这种情况下,每个对等线程都应该在它开始处理请求之前分离它自身,这样就能在它终止后回收它的内存资源了

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

  1. 全局变量
  2. 本地自动变量
  3. 本地静态变量

我们要确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥的访问

基于一种叫做信号量的特殊类型的变量是一种经典的解决同步不同执行线程问题的方法

信号量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操作是不可分割的。V中的加1操作也是不可分割的,也就是加载、加1和存储信号量的过程中没有中断
  • V的定义中没有定义等待线程被重启动的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当有多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程

Posix标准定义了许多操作信号量的函数。每个信号量在使用前必须初始化。sem_init函数将信号量sem初始化为value。sem_wait和sem_post函数来执行P和V操作

信号量提供了一种很方便的方法来确保对共享变量的互斥访问。基本思想是将每个共享变量(或一组相关的共享变量)与一个信号量s(初始为1)联系起来,然后用P(s)和V(s)操作将相关的临界区包围起来

信号量的另一个重要作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者和读者-写者问题

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

读写问题可能导致饥饿,饥饿就是一个无限期地阻塞,无法进展

服务器是由一个主线程和一组工作者线程构成的。主线程不断接受来自客户端的连接请求,并将得到的连接描述符放在一个有限缓冲区中。每一个工作者线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符

大多数现代机器具有多核处理器。并发程序通常在这样的机器上运行得更快,因为操作系统内核在多个核上并行地调度这些并发线程,而不是在单个核上顺序地调度

并发编程的一项重要教训:同步(P和V操作)开销巨大,要尽可能避免,如果无可避免,必须要用尽可能多的有用计算弥补这个开销

一种避免同步的方法是让每个对等线程在一个私有变量中计算它自己的部分和,这个私有变量不与其他任何线程共享,每个对等线程有一个不同的内存位置,也就不需要用互斥锁来保护这些更新

在一个核上运行多个线程,会有上下文切换的开销。由于这个原因,并行程序常常被写为每个核上只运行一个线程

一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果

线程不安全的函数可以与互斥锁联系起来使用,变为线程安全(保持跨越多个调用的状态的线程不安全函数,只能重写,将其变为可重入函数)

可重入函数通常比不可重入的线程安全函数更高效,因为它们不会引用任何共享数据,不需要同步操作

为了消除竞争,可以动态地为每个整数ID分配一个独立的块,并且传递给线程例程一个指向这个块的指针

一组线程被阻塞了,等待一个永远也不会为真的条件,就会产生死锁

程序员使用P和V操作顺序不当,以至于两个信号量的禁止区域重叠。重叠的禁止区阻塞了每个合法方向上的进展。换句话说,每个线程都在等待其他线程执行一个根本不可能发生的V操作

互斥锁加锁顺序规则:给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值