CSAPP小记(十到十二章)

网络编程

客户端-服务器编程模型

在这个模型中,一个应用是有一个服务器进程和多个客户端进程组成(注意是进程),无论客户端和服务器端是怎样映射到主机上,客户端-服务端模型都相同。由以下四个步骤:

1)客户端向服务器端发送请求,发起一个事务(该模型中的基本操作是事务) 
2)服务器端收到请求之后,解释它,以适当方式解释服务器端的资源(客户端需要的资源) 
3)服务器端发送响应,等待下一个请求
4)客户端收到响应,处理之。 

需要注意的是,这里的客户端和服务器端都是进程的概念。

网络

客户端和服务器端运行在不同的主机上,通过计算机的硬件和软件资源来通信,就需要网络。
在物理上,网络是一个按照地理远近组成的层次系统。
底层是LAN(local Area Network,局域网),是一个较小区域里面的网络(比如一个建筑,一个学校之类的),比最著名的是以太网(Ethernet)
一个以太网段包括一些电缆和一个集线器,局域网内的主机连接着集线器的端口,集线器将每个端口上收到的个位复制到其他端口,实现了每个主机都能看到每个位。
可以利用网桥连接多个以太网段形成较大的局域网(桥接以太网),网桥比集线器更智能化,它们可以随着时间自动学习哪个主机通过哪个端口可达。
多个不兼容的局域网通过路由器连进程一个互联网络(internet) ,最著名的是因特网(Internet)。

多个主机-->集线器连接-->形成局域网-->网桥连接-->较大的局域网-->路由器连接-->互联网

为了兼容不同的局域网,将数据通过网络发送到另一个主机,需要分离出一个中间层。有一句话,“计算机中的所有问题都可以通过增加一个中间层来解决~“
这个中间层就是一个运行在每个主机和路由器上的一个协议软件,它消除了不同网络之间的差异,控制主机和路由器协同工作实现数据传输。这个协议必须提供两种基本能力。

1)命名机制:定义一种格式一致的主机地址,这个地址唯一标示了这个主机
2)传送机制:定义一种把数据位捆扎成不连续的片(就是包)的统一方式解决传输问题。 

通过命名机制确定了网络传输的两个端点,通过传送机制在这两个端点之间传输数据。
当然其中的实现是很复杂的。

TCP/IP协议族

这张图展示了Internet上客户端和服务器端应用程序的基本硬件和软件组织。

可以看到因特网主机都实现了TCP/IP协议。
客户端和服务器端混合使用socket函数和Unix I/O函数进行通信。通常socket函数实现为系统调用,这些系统调用会陷入内核,并调用内核模式的TCP/IP函数。
TCP/IP协议是一个协议族,每一个都提供不同功能,如,IP协议提供基本的命名方法和递送机制,实现一台主机向其他主机发送包;而TCP协议构建在IP协议之上,扩展了IP协议,这样包可以在进程间传输而不是主机之间,而且提供实现机制保障传输的可靠性。

因特网是一个世界范围内的主机集合:

1)每个主机映射为32位的IP地址(其实也有128位的) 
2)IP地址被映射为因特网域名
3)主机之前的进程通过连接与其他主机上的进程通信(这里强调进程的概念) 

IP地址

现在使用的IP地址一般都是32位的(IPv4),但是少部分的IP的地址是128位的IPv6。
IP地址结构总是以大端法(网络字节顺序)的顺序存放,即使主机字节顺序是小端法,Unix提供内置函数,实现两者之间的转换。
IP地址用点分十进制来表示,每个字节用十进制表示,并用句点分开。使用函数inet_ptoninet_ntop实现IP地址和十进制的转换。

因特网域名 (domain name)

域名显示出一种层次结构,越右边,域名的层级越高。域名集合和IP地址之间的映射关系由DNS数据库(domain name system)维护。
一个域名可能映射为多个IP地址,一个IP地址也可能映射多个域名。

因特网连接

因特网的客户端和服务端通过在连接上发送和接收字节流来通信,连接是点对点的(因为连接是一对进程)。socket是连接的端点,每个socket对有对应的socket地址(由地址:端口号)组成,客户端的端口号是内核随机分配的, 而服务器端的端口是指定的知名端口。这样每个socket对就确定了两个连接的两个端点。
从Linux内核的角度来看,一个socket是通信连接的一个端点,从Linux程序的角度来看,socket是一个有相应描述符的打开文件(可以读写数据)。连接以文件描述符的形式提供给应用程序,socket接口提供打开和关闭socket描述符的函数,客户端和服务端通过读写这些socket描述符来实现彼此的通信(所以说这很像一个文件,可以打开关闭,读写)

socket的辅助函数

socket 辅助函数是一组函数,它们和unix I/O结合起来,创建网络应用。下图是一个典型的客户端-服务端上下文中的socket概述:

int socket(int domain, int type, int protocol) ;  // 成功就返回非负描述符,失败就返回-1

socket函数,创建一个socket描述符,但是这个描述符只是部分打开的,不能用于读写,具体是读还是写,取决于具体的作用(是客户端还是服务端)

int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen) ; // 成功返回0,出错返回-1 

connect函数试图与socket地址为addr的服务器建立连接,connect函数会阻塞,一旦成功,clientfd就可以读写了(确定了是客户端),并得到连接的socket对– (x:y, addr.sin_addr:addr.sin_port) x是客户端IP地址,y是临时端口,它唯一确定了客户端主机上的客户端进程。

int bind(int sockfd, const struct sockaddr *addr, socket_t adderlen) ; //  成功为0,失败为-1

bind函数是服务器用于和客户端建立连接的,和connect函数很像,addr中存着服务器端的socket地址和和端口,sockfd是socket描述符。

疑问。bind函数和connect ???

int listen(int sockfd, int backlog) ; // 成功为0,失败为-1 

服务器是等待来自客户端连接请求的被动实体。一般情况想,内核默认socket产生的描述符是存在于客户端中的主动套接字,但是,服务器调用listen 函数告诉内核,该描述符是被服务端使用的而不是客户端,将主动套接字转化为监听套接字,可以接受来自客户端的连接请求。

疑问:告诉内核,具体是什么操纵,监听套接字和主动套接字在结构上有何不同以实现不同的功能,或者说它们不同被存于内核?

int accpet(int listenfd, struct sockaddr *addr, int *addrlen) ; // 成功就返回非负连接符,失败返回-1 

accpet函数等待来自客户端的连接请求到达监听描述符,将客户端端的socket地址信息写入addr,并返回一个已连接描述符,这个描述符利用unix I/O函数与客户端进行通信。
监听描述符: 作为客户端连接请求的一个端点,,通常被创建一次,存在于服务器的整个生命周期。
已连接描述符: 作为客户端和服务器端已经建立起的连接的一个端点,服务器每次建立连接请求时都建立一次,只存在于服务器为某一个客户端服务的过程中。
监听描述符合已连接描述符的区分可以帮助建立并发服务器,每当一个连接请求到达监听描述符时,可以fork一个新进程,子进程通过已连接描述符于客户端通信。

作用如图:

主机和服务转换函数

linux提供了一些函数,实现二进制套接字地址结构和主机名,主机地址,服务名和端口号的相互转换。这些函数能帮助编写独立于任何版本的IP协议的网络程序,即可重入。
getaddrinfo函数,将主机名,主机地址,服务名和端口号的字符串表示转化为套接字地址结构,函数返回一个只想addrinfo结构的链表result,如下图:

客户端得到这个result之后,会遍历这个链表,依次尝试每个套接字地址,直到调用socketconnect函数成功,类似的,服务器也会遍历这个result,直到调用socketbind函数成功,完成之后用freeaddrinfo函数释放链表。
getnameinfo函数作用相反,将一个套接字地址结构转换成相应的主机和服务名。

其他的socket函数可以封装以上这些函数实现一些更为完整的功能,如客户端的open_clientfd函数就调用了getaddrinfo函数,socket函数和clientfd函数建立与服务器的连接。

并发编程

如果逻辑控制流有时间上的重叠,那他们就是并发的。
一般认为,并发是操作系统内核运行应用程序程序的机制,但其实应用级的并发也是很有用的,许多应用程序也需要用到并发机制。用到并发的应用程序成为并发程序,有三种实现并发程序的基本方法:

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

基于进程的并发编程

对于一个服务器,在父进程中监听一个监听描述符(如描述符3),一旦收到来自客户端的连接请求,接受连接请求之后,返回一个已经连接的描述符(如文件描述符4),父进程派生一个子进程,这个子进程获得服务器描述符表的完整副本,在子进程中关闭描述符3,在父进程中关闭描述4,然后,这个子进程为该客户端服务,父进程继续监听,等待下一个连接请求。
父进程和子进程中已连接的描述符都指向同一个文件表表项,所以需要在各自进程中关闭连接描述符的副本,否则可能会造成内存泄露,系统崩溃。
进程有个非常清晰的模型,共享文件表,但不共享用户地址空间,这样不会造成进程之间虚拟内存的覆盖,同时,也会加大进程间共享信息的难度,必须显示的使用(进程间通信,IPC)机制,但是它的缺点是比较慢。

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

I/O多路复用是指使用一个线程检查多个描述符的就绪状态,比如调用select函数,传入多个描述符,如果有一个或多个描述符准备就绪(一个或多个I/O事件发生后)就返回,否则就一直阻塞直到超时。
Linux中基于socket的通信本质就是一种I/O,使用socket函数创建的socket默认都是阻塞的,这就意味着,当socket API调用不能立即完成时,线程一直处于等待状态,直到操作完成得到结果或者操作超时出错。会引起阻塞的socket API有4中:

1. 输入操作
2. 输出操作
3. 接受连接(accept)等待对方接受连接请求,如果没有接受,线程就会进入睡眠
4. 发起连接(connect)在收到服务端的应答前不会返回。 

在服务端,通常要处理大量的socket连接请求,如果线程阻塞于上述的某一个输入或输出调用时,将无法处理其他任何运算或响应其他网络请求,这么做无疑是十分低效的,当然可以采用多线程,但大量的线程占用很大的内存空间,并且线程切换会带来很大的开销。而I/O多路复用模型能处理多个连接请求的优点就使其能支持更多的并发连接请求。
Linux支持I/O多路复用的系统调用有select、poll、epoll,这些调用都是内核级别的。
Linux提供的select相关函数接口如下:

#include <sys/select.h>
#include <sys/time.h>
int select(int max_fd, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout) ; 
FD_ZERO(int fd, fd_set* fds) ;   //清空集合
FD_SET(int fd, fd_set* fds)   ;  //将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds)  ; //将给定的描述符从文件中删除  
FD_CLR(int fd, fd_set* fds)  ;  //判断指定描述符是否在集合中
  1. select函数的返回值就绪描述符的数目,超时时返回0,出错返回-1。
  2. 第一个参数max_fd指待测试的fd个数,它的值是待测试的最大文件描述符加1,文件描述符从0开始到max_fd-1都将被测试。
  3. 中间三个参数readset、writeset和exceptset指定要让内核测试读、写和异常条件的fd集合,如果不需要测试的可以设置为NULL。

I/O多路复用可以用作并发事件驱动程序的基础。

系统级I/O

输入/输出是内存和外部设备之间复制数据的过程。
在Linux系统中,用系统级的unix I/O 实现高级语言中的输入输入函数(如 printf, scanf)

模型化为文件

在Linux系统中,一切都可以看作是文件,同样,可以把I/O设备模型化为文件,输入,输出作为相应文件的读写来进行。
这种将设备映射为文件,允许内核引出一个简单,低级的应用接口的方式就叫unix I/O :
1) 打开文件 : 访问相应I/O设备时,相当于打开一个文件,返回一个文件描述符(一个非零整数),在后续的操作中标识符代表这个文件,内核记录这个打开文件的相关信息,应用程序只需记住这个文件表示符。
2) 标准输入标准输出标准错误,Linux shell创建每个进程时,都要打开这个三个文件,文件标识符分别为(0,1,2),所以别的文件标识符从3开始。
3) 当前文件位置 : 在内核中保存每个打开文件的文件位置k,初始化0,这是从文件开头起始的文件偏移量。
4) 读写文件 : 读操作是从文件复制到内存,写操作是从内存复制到文件,读写以字节为单位,读写结束之后,文件位置k做相应改变,如果,要读的字节不够,会出发一个end-of-file(EOF),文件结尾并没有EOF。
5) 关闭文件 : 通知内核关闭文件,内核释放文件打开时创建的数据结构,并把这个文件描述符恢复为可用。

文件

每个Linux文件都有其相应的角色
1) 普通文件 : 应用程序将其分为文本文件二进制文件文本文件只包含ascii码或unicode字符, 二进制文件是用二进制编码表示的文件,对内核来说,两者没有区别.
2) 目录 : 目录是包含一组链接的文件,每个链接映射到一个文件,目录中也有目录,每个目录都有 . , ..这两个目录链接,分别是到自身和上一级目录的链接。
3) socket : 用来与另一进程跨网络通信的文件。

作为上下文的一部分,每个进程都有当前工作目录。

打开和关闭文件

使用 open 打开文件

int open(char *filename , int flags , mode_t mode ) ; //  成功返回文件描述符,失败返回-1 

open函数返回一个文件描述符(总是当前进程中没有打开的最小描述符)
flags 参数指明如何访问,是一个或多位掩码的或,表示只读,只写或其他设置。
mode 参数定义文件访问权限 。 作为上下文的一部分,每个进程都有一个umask,调用umask函数来设置(文件的在这个进程中的读写权限,比如同一个组的成员能否读写)

使用close 来关闭一个文件

int close(int fd) ;  // 成功返回0,出错返回-1 

读写文件

使用 writeread函数来实现读写.

sszie_t read(int fd, void *buf, size_t n ) ;  // 成功就返回读的字节数,失败返回-1 ,EOF返回0 

从当前文件复制到内存位置buf中。

ssize_t write(int fd , const void *buf , size_t n ) ; //  成功返回写入的字节数,出错返回-1 

从内存位置buf复制到当前文件中。

在某些情况下,readwrite函数复制的字节比应用程序要求的少,返回不足值不代表错误,所以使用RIO自动处理不足值, RIO函数分为2类:
1) 无缓冲区的输入输出函数: 直接在文件和内存之间传输,没有应用级缓冲区,对将二进制数据读写到网络和从网络读写二进制数据尤其有用。
2) 有缓冲区的输入函数: 高效地读写文本和二进制文件,可以为printf函数提供缓冲区。带缓冲的RIO函数是线程安全的,在同一个描述符可以交错地被使用。

无缓冲区的输入输出函数

输出:

sszie_t rio_readn(int fd , void *usrbuf , size_t n ) ; // 遇到EOF返回0,成功返回读入的字节数,出错返回-1 

输入:

sszie_t rio_writen(int fd , void *usrbuf , size_t n ) ; //  出错返回-1,成功返回写入的字节数

对同一个描述符,可以交替使用rio_readnrio_writen 。 如果输入输出时被应用信号中断,rio_readnrio_writen函数会重新启动read函数和write函数 。
可以理解为rio_readnrio_writen是对 readwrite函数的封装,其中处理了不足值和被应用级信号中断的问题,保证不会出现不足值,只有readwrite函数本身错误才会返回-1

有缓冲区的输入函数

每次调用read函数的时候,都会陷入内核(文件描述符代表的文件信息储存在内核),如果需要统计一个文件中的文本行数,不用缓冲区的话,要一个字节一个字节地从文件中读取到内存(调用read函数)每次都会陷入内核,这样效率不高,所以需要使用带缓冲区的rio输入输出函数,先把内容读取到缓冲区,再从缓冲区读到指定的内存区。

void rio_readinitb(rio_t *rp , int fd ) ; 

rio_readinitb 函数初始化创建了一个新的缓冲区,并将一个打开文件的文件描述符与这个缓冲区联系起来 。 rio_readinitb函数 :

void rio_readinitb(rio_t *rp , int fd){
    rp->rio_fd = fd ; 
    rp->rio_cnt = 0 ; 
    rp->rio_bufptr = rp->rio_buf ; 
}

定义rio_t 结构体:

#define RIO_BUFSIZE 8192 
typedef struct {
    int rio_fd ; // 文件描述符 
    int rio_cnt ; // 缓冲区中未读的字节数
    char *rio_bufptr ; // 指向缓冲区未读部分的指针  
    char rio_buf[RIO_BUFSIZE] ; // 缓冲区 
} rio_t ; 

RIO读程序的核心是rio_read函数:

static ssize_t rio_read( rio_t * rp , char *usrbuf , size_t n ) {
    while ( rp->rio_cnt <= 0 ) { 
        rp->rio_cnt = read(rp->rio_fd,rp->buf,sizeof(rp->buf)) ; 
        if ( rp->rio_cnt < 0 ) {
            if ( error != EINTR )  // sig handler return 
                return -1 ; 
        } 
        else if ( rp->rio_cnt == 0 )  // EOF
            return 0 ; 
        else 
            rp->rio_bufptr = rp->rio_buf ; 
    } 

    cnt = min(rp->rio_cnt,n) ; 
    memcpy(usrbuf,rp->rio_bufptr,cnt) ; 
    rp->rio_bufptr += cnt ; 
    rp->rio_cnt -= cnt ; 
    return cnt ; 
}

rio_read函数,如果缓冲区为空(rp->rio_cnt < 0),就调用read函数从文件中读取制定字节到缓冲区,在这过程中,如果被信号打断,就重新调用read函数,如果读取错误,返回-1,如果遇到EOF,返回0,读取正常,就将缓冲区的指定字节(n和rp->rio_cnt中的较小值,有可能要读取的字节比缓冲区的字节大,所以取两者的较小值)的复制到用户缓冲区(usrbuf),并相应改变rp->cnt的值。

这样,我们就可以每次都从缓冲区读取,就不用每次都陷入内核。

读取文件元数据

关于文件的信息称为元数据.

int stat(const char *filename, struct stat *buf) ;     //以文件名作为输入
int fstat(int fd, struct stat *buf) ;             //以文件描述符作为输入

statfstat函数将文件转化为元数据, 也就是是 stat结构体 :

struct stat {
    dev_t       st_dev ;     /*device*/ 
    ino_t       st_ino ;     /*inode*/
    mode_t      st_mode ;    /*文件类型与文件访问许可位*/
    nlink_t     st_nlink ;   /*hard links的number*/
    uid_t       st_uid ;     /*User ID of owner*/
    gid_t       st_gid ;     /*Group ID of owner*/
    ...
    off_t       st_size ;    /*文件的字节大小*/
    ...
    ...
}; 

st_mode表示文件访问许可位和文件类型,可以用以下宏谓词来确定文件类型:

S_ISREG(m)  m是一个普通文件吗?
S_ISDIR(m)   m是一个目录文件吗?
S_ISSOCK(M)  m是一个套接字吗?

读取目录内容

readdir系列的函数读取目录的内容

DIR *opendir(const char *name) ; // 以路径为参数,返回指向目录流的指针,若出错,返回NULL 
struct dirent *readdir ( DIR *dirp) ; // 若成功,返回指向下一个目录项的指针,若没有或出错,则返回NULL 
int closedir (DIR *dirp) ; // 关闭并释放所有资源 

每个目录项都有一个结构:

struct dirent {
    ino_t d_ino ; // 文件位置 
    char  d_name[256] ; // 文件名 
} ; 

共享文件

内核用三个相关的数据结构表示打开的文件

1) 描述符表 : 每个进程都有独立的描述符表,它的表项是这个进程打开的文件的文件描述符,每个表项指向文件表的一个表项。
2) 文件表 :所有进程共用一个文件表,表示所有打开的文件的集合,包括的列有文件位置,引用计数,以及一个指向v-node表中对应表项的指针.内核会在一个表项的引用计数为0时删除一个表项.
3) v-node表 : 所有进程共用一张表,每个表项包括st_mode, st_size等stat结构中的大多数信息(相当于每个文件的详细信息,而不是像文件表中那样比较笼统的信息)

多个描述符可以同一个文件表项来引用同一个文件,如用同一个filename调用open函数两次,关键思想是每个描述符都有自己的文件位置(这里的文件位置表示的文件中已读的字节位置):

父子进程共享文件

fork之后,子进程有一个父进程描述符表的副本,子进程增加了对于文件表中相应文件的引用次数.父子进程共享相同的文件位置(这里的文件位置是文件中已读的字节位置),所以,在内核删除相应文件表项之前,要求父子进程都关闭了它们的描述符.

I/O重定向

在shell中,可用 >< 符号来重定向输出到文件。 如 : ls > foo.txt

还有就是使用dup2函数:

int dup2(int oldfd, int newfd);

dup2()函数复制该进程的描述符表中的oldfd的表项到newfd表项。覆盖newfd之前的内容。如果newfd已经打开了,dup2()会在复制oldfd之前关闭newfd。dup2()会更改文件引用次数,如图:

调用dup2函数之后,两个文件描述符都指向文件B;文件A已经关闭,它的文件表v-node表表项也已经别删除了,文件B的引用次数增加了。

标准I/O即<stdio.h>

C语言定义一组高级输入输出函数:
1).打开关闭文件的函数:fopen(), fclose()
2).读写字节的函数:fread(), fwrite()
3).读写字符串:fgets(), fputs()
4).复杂格式化I/O:scanf(), printf()

标准I/O库将一个打开的文件模型化一个指向FILE类型的结构的指针,也叫
每个C程序都要打开三个流,stdin,stdout,stderr,分别是标准输入流,标准输出流,标准错误流(我的理解是打开这三个文件)
FILE类型的流是对文件描述符和流缓冲的的抽象,因为Linux I/O函数开销较大,每次调用都要陷入内核。如果用一次调用read填充缓冲区,每次就能直接从缓冲区获得服务,就减轻了负担,增加了效率。

综合:该使用哪些I/O函数

Unix I/O, 标准I/O,RIO之间的关系:

Unix I/O基于操作系统内核实现,高级别的RIO和标准I/O基于unix I/O实现。有几个建议:

1) 尽可能多的使用标准I/O
2) 不要使用scanfrio_readlineb 读二进制文件。
3) 对网络socket的I/O使用RIO函数,因为对socket和流的限制有时会冲突(不兼容)。