第十章 系统级IO
10.1 Unix I/O
- *inx下的文件
所有的IO设备(网络、磁盘、终端等)都被模型化为文件,而所有输入输出都被当做对相应文件的读写来执行。 - 自己对文件的理解
通常所说的文件一般指磁盘文件,但是想到磁盘也是IO设备,从而就不难理解可将所有IO设备都模型化为文件,从而统一/简化对外设中数据的操作 - 对文件的操作
打开文件:由系统调用打开文件,返回一个整数文件描述符,描述符标志这个文件;描述符由进程的PCB管理
特殊描述符:标准输入(0)、标准输出(1)、标准错误(2)
改变当前文件位置:文件位置k标志从文件开头的字节偏移,通过seek可修改
读写文件:一个读操作就是从文件当前位置k开始复制n个字节到内存;k大于字节总数时会触发EOF条件;EOF并不是字符,它只是由内核检测到的一种条件。应用程序在它接收到一个由read()函数返回的零返回码时,它就会发出EOF条件;写操作同理…
关闭文件:释放相关数据结构,恢复描述符到描述符池
10.2 文件
- 文件类型
普通文件:文本文件是只含有ASCII或Unicode字符的普通文件,二进制文件是所有其他文件;对内核而言,文本文件和二进制文件没有区别
目录:包含一组链接的文件(即一个目录包含多个链接,每个链接指向子目录或者文件)
套接字:进程跨网络通信的文件
其他类型的文件:其他文件类型包含命名通道、符号链接、字符设备、块设备(这里的文件不是磁盘上的文件,而是Linux将其抽象为文件) - 目录与进程
作为其上下文的一部分,每个进程都有一个当前工作目录来确定其在目录层次结构中的当前位置!!
10.3 打开和关闭文件
/*
* filename:文件名
* flags:文件打开方式 => 只读、只写、可读可写
* mode:在创建文件时设置文件的访问权限!!
* return:返回标志这个文件的描述符/整数
*/
int open(char* filename,int flags,mode_t mode);
/*
* fd:要关闭的文件的描述符
*/
int close(int fd);
10.4 读和写文件
-
读写的系统调用
/* * 从fd文件的 当前文件位置开始 读取最多n个字节到内存缓冲buf中 * 注:ssize_t是符号数 size_t是无符号数 * return: 返回的称为不足值!! => 见下面说明 */ ssize_t read(int fd,void *buf, size_t n); ssize_t write(int fd,const void *buf,size_t n);
-
不足值
要求传输的数据多于真实数据时,返回真实数据的字节数,这个字节数就是不足值; 特殊的不足值=> EOF(文件结尾)、-1(错误)
10.5 RIO包
-
作用
RIO包由csapp作者编写,相比于Unix/IO能更好地处理不足值并提供缓冲;同时在网络编程问题上比C语言标准IO更优秀(10.11)
注:本节不是UnixIO的内容,不过是很好的学习材料 -
RIO无缓冲的输入输出函数
/* 从fd对应的文件中读取n个字节到内存usrbuf中*/ ssize_t rio_readn(int fd, void *usrbuf, size_t n) { size_t nleft = n; ssize_t nread; char *bufp = usrbuf; //while循环解决网络环境中可能无法一次读取到指定字节数的情况 while (nleft > 0) { if ((nread = read(fd, bufp, nleft)) < 0) { //nread 是实际读取到的字节数=>不足值 if (errno == EINTR) /* 如果被信号处理程序中断,需要重新读取 */ nread = 0; /* and call read() again */ else return -1; /* errno set by read() */ } else if (nread == 0) break; /* EOF */ nleft -= nread; bufp += nread; } return (n - nleft); /* Return >= 0 */ }
/*从内存usrbuf处写n个字节到fd对应的文件*/ ssize_t rio_writen(int fd, void *usrbuf, size_t n) { size_t nleft = n; ssize_t nwritten; char *bufp = usrbuf; while (nleft > 0) { if ((nwritten = write(fd, bufp, nleft)) <= 0) { if (errno == EINTR) /* Interrupted by sig handler return */ nwritten = 0; /* and call write() again */ else return -1; /* errno set by write() */ } nleft -= nwritten; bufp += nwritten; } return n; }
-
RIO带缓冲的输入函数(没有输出!)
1.缓冲区的数据结构
作用:一次从文件读取较多内容到内存缓冲区,应用程序从内存缓冲区取数据而非直接从文件取数据 => 减少系统调用的次数,提高效率#define RIO_BUFSIZE 8192 typedef struct { int rio_fd; /* Descriptor for this internal buf,文件描述符 */ int rio_cnt; /* Unread bytes in internal buf, 缓冲区中未读的字节数*/ char *rio_bufptr; /* Next unread byte in internal buf, 下一次读时的起始地址 */ char rio_buf[RIO_BUFSIZE]; /* Internal buffer,内存中的读缓冲区 */ } rio_t;
2.rio_readinitb()函数
void rio_readinitb(rio_t *rp,int fd);
函数将打开的文件描述符fd和内存中的读缓冲区联系起来,函数定义如下:void rio_readinitb(rio_t *rp, int fd) { rp->rio_fd = fd; rp->rio_cnt = 0; rp->rio_bufptr = rp->rio_buf; }
3.rio_read()函数
ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n);
函数是Linux中read()系统调用的带缓冲版本,它是另外两RIO带缓冲输入函数(rio_readlineb、rio_readnb)的基础;其定义如下:static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n) { int cnt; /*如果缓冲区中内容为空(即未读字节数<=0), 通过read()系统调用填满缓冲区 */ while (rp->rio_cnt <= 0) { /* Refill if buf is empty */ rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf)); if (rp->rio_cnt < 0) { if (errno != EINTR) /* Interrupted by sig handler return */ return -1; } else if (rp->rio_cnt == 0) /* EOF */ return 0; else rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */ } /* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */ /*读取指定字节数到用户程序的usrbuf中,当然可能遇到不足值*/ cnt = n; if (rp->rio_cnt < n) cnt = rp->rio_cnt; memcpy(usrbuf, rp->rio_bufptr, cnt); rp->rio_bufptr += cnt; rp->rio_cnt -= cnt; return cnt; }
4.rio_readlineb()函数
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
函数从内存缓冲区中读取一个文本行(包括换行符)到用户程序的usrbuf中,主要用于读取文本文件;其定义如下:ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) { int n, rc; char c, *bufp = usrbuf; for (n = 1; n < maxlen; n++) { if ((rc = rio_read(rp, &c, 1)) == 1) { /*每次从缓冲区读取一个字节,存放到字符c*/ *bufp++ = c; /*将读取的字符放到用户程序相应内存区域*/ if (c == '\n') { /*遇到换行符*/ n++; break; } } else if (rc == 0) { if (n == 1) return 0; /* EOF, no data read */ else break; /* EOF, some data was read */ } else return -1; /* Error */ } *bufp = 0; /*读取一行字符后,在末尾添加NULL,表示结尾; 可见最多读取maxlen-1字节*/ return n-1; }
5.rio_readnb()函数
ssize_t rio_readnb(rio_t * rp, void *usrbuf, size_t n);
函数从缓冲区读取n个字节到用户程序的usrbuf中,它既可以用于读取文本文件,也可以用于读取二进制文件;其定义如下:ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) { size_t nleft = n; ssize_t nread; char *bufp = usrbuf; while (nleft > 0) { if ((nread = rio_read(rp, bufp, nleft)) < 0) return -1; /* errno set by read() */ else if (nread == 0) break; /* EOF */ nleft -= nread; bufp += nread; } return (n - nleft); /* return >= 0 */ }
10.6 读取文件元数据
- 文件元数据的数据结构
注:这一结构体定义在<sys/stat.h>头文件中,通常位于/usr/include/sys/stat.hstruct stat{ dev_t st_dev; /*Device*/ ino_t st_ino; /*inode*/ mode_t st_mode; /*Protection and file type*/ nlink_t st_nlink; /*Number of hard links*/ uid_t st_uid; /*User ID of owner*/ gid_t st_gid; /*Group ID of owner*/ dev_t st_rdev; /*Device type (if inode device)*/ off_t st_size; /*Total size, in bytes*/ unsigned long st_blksize; /*Block size for filesystem I/O*/ unsigned long st_blocks; /*Number of blocks allocated*/ time_t st_atime; /*Time of last access*/ time_t st_mtime; /*Time of last modification*/ time_t st_ctime; /*Time of last change*/ };
- 读取文件元数据的系统调用
应用程序可通过stat和fstat函数,检索到文件的元数据
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
他们以文件名或者文件描述符作为输入,然后将元数据填充到buf中!
10.7 读取目录内容
-
打开目录
DIR *opendir(const char *name);
输入目录的路径名,返回指向目录流的指针。流是对条目有序列表的抽象,在这里是指目录项的列表。 -
读取目录内容
struct dirent *readdir(DIR *dirp);
每次对readdir的调用返回的都是指向流dirp中下一个目录项的指针,如果没有更多的目录项,则返回NULL。目录项dirent结构如下:struct dirent{ ino_t d_ino; /*inode number, 文件位置*/ char d_name[256]; /*Filename,文件名*/ };
-
关闭目录
int closedir(DIR *dirp);
函数losedir关闭流并释放其所有资源 -
示例程序
int main(int argc, char **argv) { DIR *streamp; struct dirent *dep; if (argc != 2) { printf("usage: %s <pathname>\n", argv[0]); exit(1); } streamp = Opendir(argv[1]); errno = 0; while ((dep = readdir(streamp)) != NULL) { printf("Found file: %s\n", dep->d_name); } if (errno != 0) unix_error("readdir error"); Closedir(streamp); exit(0); }
10.8 共享文件
-
表示打开文件的数据结构
描述符表:每个进程有它独立的描述符表;表项由进程打开文件描述符来索引,描述符表的每个表项指向文件表中的一个表项
打开文件表:所有进程共享文件表,它表示打开文件的集合;文件表的每个表项包括当前的文件位置、引用计数、指向v-node表项的指针;关闭一个描述符会减少相应文件表表项中的引用计数,直到引用计数为零,内核才会删除该表项
v-node表:所以进程共享v-node表,每个表项包含stat结构(见10.6)中的大多数信息
【补充】:1.文件真正的索引节点是inode,它会持久到硬盘中,inode中有一个引用计数
,这个引用计数表示该文件的硬链接数;2.当inode节点被调到内存,会增加一些临时的信息与inode一起,构成vnode节点! 3.打开文件表中的引用计数
,与inode中的引用计数完全是两码事,前者表示的是打开该文件的进程数
-
文件共享
多个描述符可以通过不同的文件表表项来引用同一个文件;如果以同一个filename调用open函数两次,就会发生这种情况(两个不同的文件描述符,两个不同的文件表项,但是对应同一个v-node表项);关键思想是每一个描述符/文件表项都有它自己的文件位置(即k,可用seek函数修改),所以对不同描述符的读操作可以从文件的不同位置获取数据
补充:硬链接的本质就是增加文件表表项的引用计数 -
父子进程如何共享文件
假设调用fork()之前,父进程有图10-12所示的打开文件,调用fork()后,子进程拥有父进程描述符表的副本,同时内核会修改文件表表项中的引用计数!;注意,在内核删除相应文件表表项之前,父子进程都必须关闭了它们的描述符
10.9 I/O重定向
- dup2函数重定向原理
int dup2(int oldfd, int newfd);
函数复制描述符表表项oldfd到描述符表表项到newfd,覆盖描述符表表项newfd以前的内容;dup2会在复制之前关闭newfd,删除newfd对应的文件表和v-node表表项
10.10 标准I/O
- 什么是标准I/O
1. C语言定义了一组高级输入输出函数,称为标准I/O库,带有缓冲,用于提供Unix I/O的较高级别替代;
2. 标准I/O库将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向FILE类型的结构的指针。类型为FILE的流是对文件描述符和流缓冲区的抽象;
3. 每个ANSI C程序开始时都有三个打开的流stdin、stdout、stderr;
10.11 综合:我该使用哪些IO函数?
略
10.12 小结
-
较大收获/易忘易混
1.所有输入输出都被当做对相应文件的读写来执行(此文件不是通常意义下的磁盘文件,而是将相应的IO设备抽象成文件)!!
2.目录也是文件,只是这类文件中的内容是链接!!
3.什么是流(流是对条目有序列表的抽象)
4.表示打开文件的数据结构(三个表的对应关系)
5.文件共享的原理
6.IO重定向原理 -
疑惑
如何理解所有IO设备都被模型化为文件?
第十一章 网络编程
11.1 客户端-服务器编程模型
- 客户端-服务器模型
这个模型中,一个应用是由一个服务器进程和一个或者多个客户端进程组成;服务器管理资源并向客户端提供服务 - 注意
客户端和服务器是进程,而不是经常提到的机器或者主机;一台主机可以同时运行许多不同的客户端和服务器
11.2 网络
- 网络是一种IO设备
对主机而言,网络只是又一种IO设备,是数据源和数据接收方;
一个插到IO总线扩展槽的适配器提供了到网络的物理接口。从网络上接收到的数据从适配器经过IO和内存总线复制到内存,通常是通过DMA传送。如图所示:
- 网络更多细节
…略,本书写得并不清楚 => 参考计算机网络相关书籍 - 网络中的数据传送
数据从一台主机传送到另一台主机的过程如图所示:
11.3 全球IP因特网
-
IP地址
IP地址的表示方法:一个IP地址就是一个32位无符号整数,不过它存放在一个结构体中:/* IP address structure */ struct in_addr { uint32_t s_addr; /* Address in network byte order (big-endian) */ };
大小端:网络统一使用大端字节顺序(即数据的字节保存在内存的低地址中,与阅读习惯一致)
-
因特网域名
域名层次结构如下:
注意:域名和IP是多对多的关系 => 一个IP可以对应多个域名,一个域名也可以对应多个IP -
因特网连接
从连接一对进程的意义上而言,因特网连接是点对点的;
一个套接字是连接的一个端点,用(IP:端口)表示;
客户端套接字地址中的端口是由内核自动分配的(临时端口),而服务端套接字地址中的端口通常是某个知名端口;
因特网连接示意图如下:
11.4 套接字接口
-
概述
从Linux内核的角度,一个套接字就是通信的一个端点; 从Linux程序的角度来看,套接字就是一个有相应描述符的文件
套接字接口是一组函数,他们和Unix I/O函数结合起来,用以创建网络应用。如图所示:
-
套接字地址结构
套接字地址存放在如下16字节的结构体sockaddr_in结构中(最后8字节的sin_zero是为了强制转换时与sockaddr对齐):/* IP socket address structure */ struct sockaddr_in { uint16_t sin_family; /* Protocol family (always AF_INET) */ uint16_t sin_port; /* Port number in network byte order */ struct in_addr sin_addr; /* IP address in network byte order */ unsigned char sin_zero[8]; /* Pad to sizeof(struct sockaddr) */ };
上面的套接字结构只适用于IP网络,更通用的结构sockaddr如下(仍然是16字节,所以两者的类型可以强制转换,经常使用这个通用结构的指针):
/* Generic socket address structure (for connect, bind, and accept) */ struct sockaddr { uint16_t sa_family; /* Protocol family */ char sa_data[14]; /* Address data */ };
-
socket函数(客户端服务端创建套接字)
客户端和服务器使用socket()函数来创建一个套接字描述符:
int socket(int domain, int type, int protocal);
其中,参数的含义??
socket()返回一个文件描述符,不过这个套接字(文件)只是部分打开,暂时不能用于读写;客户端/服务端完全打开套接字的方式有所不同 -
connect函数(客户端打开套接字)
客户端通过调用connect()函数来建立和服务器的连接:
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
其中clientfd是socket()函数返回的文件描述符;addr是服务端的套接字(客户端随机提供一个端口与IP组成客户端套接字)
connect()函数试图与套接字地址为addr的服务器建立连接;它会阻塞(当前进程),直到连接成功建立或者发生错误 => 如果成功,clientfd描述符就可以用于套接字的读写了 -
bind、listen、accept函数(服务端打开套接字)
剩下的套接字:bind()、listen()、accept()函数,服务器用于建立连接;
bind:int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
其中sockfd是服务端socket()函数返回的套接字描述符,addr仍然是服务端套接字,与connect相同(bind函数告诉内核将addr中的服务端套接字地址和套接字描述符sockfd联系起来)
listen:int listen(int sockfd, int backlog);
客户端是发起请求连接的主动实体,服务端是等待连接的被动实体;
默认情况下,内核认为socket()函数创建的描述符是客户端的主动套接字;
listen()函数将scokfd从主动套接字转换为监听套接字!!
accept:int accept(int listenfd, struct sockaddr *addr, int *addrlen);
其中,listenfd是服务端socket、bind函数对应的侦听描述符,addr是客户端套接字;
accept函数会阻塞当前服务端进程,等待来自客户端的请求到达侦听描述符listenfd,然后在addr中填写客户端套接字地址,并返回一个已连接描述符,已连接描述符用于与客户端通信 -
侦听描述符和已连接描述符
监听描述符作为客户端连接请求的一个端点,它通常被创建一次并存在于服务器的整个生命周期
已连接描述符是客户端和服务端之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中
区分两种描述符的意义:可用于建立并发服务器 => 每次一个连接请求到达监听描述符时,都可以派生出一个新的进程,它通过已连接描述符与客户端通信
监听描述符和已连接描述符的角色如图所示:
-
建立连接过程中的难点小结
1. 服务端和客户端都需要使用socket函数创建一个套接字描述符;在服务端这个描述符将用于监听;在客户端这个描述符将直接用于通信
2. socket()函数创建套接字后服务端还需要使用bind()函数和listen()函数;bind将监听描述符和监听的套接字(服务端套接字)联系起来;listen将主动套接字转换为监听套接字;这一阶段客户端没有与之对应的操作
3. 客户端的connect函数和服务端的accept函数是相对应的;connect将服务端套接字与clientfd绑定;accept将客户端套接字与返回的已连接描述符绑定 -
主机和服务的转换
getaddrinfo()函数
getaddrinfo()将主机名/主机地址、服务名/端口号的字符串表示转换成套接字地址结构,它就相当于一个DNS,其声明如下:int getaddrinfo(const char *host, //主机名或者主机地址 const char *service, //服务名或者端口号 const struct addrinfo *hints, //可选参数,详见官方文档 struct addrinfo ** result); //返回结果,指向一个addrinfo结构的链表 void freeaddrinfo(struct addrinfo *result); // 释放getaddrinfo生成的链表
addrinfo结构如下,其中包含一个指向套接字地址结构(sockaddr)的指针ai_addr,同时还包含了socket()函数需要的参数 => 所以常用getaddrinfo()自动生成socket()函数的参数:
struct addrinfo { int ai_flags; /* Hints argument flags */ int ai_family; /* First arg to socket function */ int ai_socktype; /* Second arg to socket function */ int ai_protocol; /* Third arg to socket function */ char *ai_canonname; /* Canonical hostname */ size_t ai_addrlen; /* Size of ai_addr struct */ struct sockaddr *ai_addr; /* Ptr to socket address structure */ struct addrinfo *ai_next; /* Ptr to next item in linked list */ };
getaddrinfo()生成的结果由result指针指向,它是一个链表,每个节点对应一个套接字地址(因为一个主机名/域名可能对应多个IP,所以返回的是链表):
getnameinfo()函数
getnameinfo()函数与getaddrinfo()相反,它将套接字地址结构转换成相应的主机和服务名字符串,其声明如下:int getnameinfo(const struct sockaddr *sa, //将要转换的套接字地址结构 socklen_t salen, char *host, //主机名缓冲区 size_t hostlen, char *service, //服务名缓冲区 size_t servlen, int flags); //掩码,用于修改一些默认行为,详见P659
-
套接字接口的辅助函数
1.open_clientfd()函数:可将socket和connect封装成一个叫做open_clientfd的函数,用于和服务端建立连接;声明如下:
int open_clientfd(char *hostname, char *port);
其中hostname和port是服务端的主机名和端口号,返回一个打开的套接字描述符,且该描述符已经准备好,可直接用于读写int open_clientfd(char *hostname, char *port) { int clientfd, rc; struct addrinfo hints, *listp, *p; /* Get a list of potential server addresses */ memset(&hints, 0, sizeof(struct addrinfo)); hints.ai_socktype = SOCK_STREAM; /* Open a connection */ hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */ hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */ if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) { fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n", hostname, port, gai_strerror(rc)); return -2; } /* Walk the list for one that we can successfully connect to */ /* 遍历链表,直到能够建立连接为止*/ for (p = listp; p; p = p->ai_next) { /* Create a socket descriptor */ if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue; /* Socket failed, try the next */ /* Connect to the server */ if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1) break; /* Success */ if (close(clientfd) < 0) { /* Connect failed, try another */ //line:netp:openclientfd:closefd fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno)); return -1; } } /* Clean up */ freeaddrinfo(listp); if (!p) /* All connects failed */ return -1; else /* The last connect succeeded */ return clientfd; }
2.open_listenfd()函数:可将socket、bind、listen函数封装成open_listenfd,用于创建一个监听描述符(只是监听描述符,并非准备好读写用于通信的已连接描述符),这个描述符准备好在端口port上接收连接请求;声明如下:
int open_listenfd(char *port);
其中port就是服务端的端口,返回的是一个监听描述符int open_listenfd(char *port) { struct addrinfo hints, *listp, *p; int listenfd, rc, optval=1; /* Get a list of potential server addresses */ memset(&hints, 0, sizeof(struct addrinfo)); hints.ai_socktype = SOCK_STREAM; /* Accept connections */ hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */ hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */ /*hints.ai_flags = AI_PASSIVE 以及 getaddrinfo的host字段设置为NULL => 只获取本主机的相关信息 */ if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) { fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port, gai_strerror(rc)); return -2; } /* Walk the list for one that we can bind to */ for (p = listp; p; p = p->ai_next) { /* Create a socket descriptor */ if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0) continue; /* Socket failed, try the next */ /* Eliminates "Address already in use" error from bind */ setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, //line:netp:csapp:setsockopt (const void *)&optval , sizeof(int)); /* Bind the descriptor to the address */ if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0) break; /* Success */ if (close(listenfd) < 0) { /* Bind failed, try the next */ fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno)); return -1; } } /* Clean up */ freeaddrinfo(listp); if (!p) /* No address worked */ return -1; /* Make it a listening socket ready to accept connection requests */ if (listen(listenfd, LISTENQ) < 0) { close(listenfd); return -1; } return listenfd; }
-
echo客户端和服务器的示例
参考11.4.9(P663、示例代码netp/ecoclient.c等),结合第10章相关内容
补充:读写数据常使用到EOF,对EOF的理解如下:EOF并不是字符,它只是由内核检测到的一种条件。应用程序在它接收到一个由read()函数返回的零返回码时,它就会发出EOF条件…
11.5 Web服务器
- Web基础
…主要就是http协议 - Web内容
web中传输的内容:对于web客户端和服务端而言,内容是与一个与MIME类型相关的字节序列,常用的MIME类型如下:
web服务器向客户端提供内容的方式 =>
静态内容:取一个磁盘文件,并将它的内容返回给客户端
动态内容:运行一个可执行文件,并将它的输出返回给客户端
URL:…注意,确定一个URL指向的是静态内容还是动态内容没有标准的规则,每个服务器有自己的理解 - HTTP事务
HTTP请求:…略,参考计算机网络书籍
HTTP响应:…略,参考计算机网络书籍 - 服务动态内容
1.客户端如何将参数传递给服务器:GET在URL中传递;POST在请求体中传递
2.服务端如何将参数传递给子进程:服务器调用fork()创建子进程,然后调用execve(参考8.4)在子进程的上下文中执行CGI程序,调用execve之前,子进程将查询的参数设置为环境变量,从而将参数传递个CGI程序
3.服务器如何将其他信息传递给子进程:也是通过设置环境变量…
4.子进程将它的输出发送到哪里:CGI程序将它的动态内容发送到标准输出。在子进程加载并运行CGI程序之前,它使用Linux dup2函数将标准输出重定向到和客户端相关联的已连接描述符,从而将动态输出的内容返回给客户端
11.6 综合:TINY Web服务器
待完成
11.7 小结
-
较大收获/易忘易混
1.客户端和服务端通过套接字建立连接的过程
2.服务端提供动态内容的过程(参数如何传递、数据如何返回) -
疑惑
一个端口只能绑定一个进程吗??
客户端套接字描述符、监听描述符、已连接描述符的三角关系???
第十二章 并发编程
- 概述
并发:逻辑控制流在时间上重叠,那么它们就是并发的(详见8.2)
现代操作系统提供了三种基本的构造并发程序的方法:
进程、I/O多路复用、线程
…
12.1 基于进程的并发编程
- 基于进程的并发服务器原理
第一步:服务器打开了一个监听描述符3,接受客户端1的请求后打开一个已连接描述符4
第二步:服务器派生一个子进程,子进程关闭副本中的监听描述符3,而父进程关闭已连接描述符4,然后子进程提供服务
第三步:…
第四步:…
注意:因为父子进程中的已连接描述符都指向同一个文件表表项,所以父进程关闭它的已连接描述符的副本至关重要
- 基于进程的并发服务器程序
#include "csapp.h" void echo(int connfd); void sigchld_handler(int sig) //line:conc:echoserverp:handlerstart { /*SIGCHLD信号是阻塞的,且Linux信号不排队, 这里使用循环是为了回收多个僵死的子进程 */ while (waitpid(-1, 0, WNOHANG) > 0); return; } int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } Signal(SIGCHLD, sigchld_handler); /*提供信号处理程序:回收僵死的子进程*/ listenfd = Open_listenfd(argv[1]); while (1) { clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen); if (Fork() == 0) { Close(listenfd); /* 子进程关闭其监听描述符*/ echo(connfd); /* Child services client */ Close(connfd); /* Child closes connection with client */ exit(0); /* Child exits */ } Close(connfd); /*父进程需要关闭已连接描述符*/ } }
- 进程的优劣
基于进程的并发编程模型导致:进程共享文件表,但是不共享地址空间
优点:一个进程不可能不小心覆盖另一个进程的虚拟内存,可以消除很多错误
缺点:进程间共享状态信息变得困难,必须使用IPC机制(进程间通信);此外创建、撤销进程以及进程IPC都会有很高的开销 - 关于IPC
主要有四种方式:管道、先进先出、系统V共享内存、系统V信号量
…待学习…
12.2 基于I/O多路复用的并发编程(重要)
-
I/O多路复用
I/O多路复用的基本思想:使用select函数,要求内核挂起进程,只有在一个或多个I/O事件发生后,才将控制返回给应用程序(即只有一个进程,多个I/O共享这个进程,没有I/O时进程阻塞)
select函数:int select(int n, fd_set *fdset, NULL,NULL,NULL);
fd_set是一个描述符集合,将其看成大小为n的位向量,每位对应于一个描述符。select函数会一直阻塞,直到描述符集合中至少有一个描述符准备好可以读(select详细使用参考P685)
示例程序#include "csapp.h" void echo(int connfd); void command(void); int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; fd_set read_set, ready_set; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); FD_ZERO(&read_set); /* Clear read set */ FD_SET(STDIN_FILENO, &read_set); /* Add stdin to read set */ FD_SET(listenfd, &read_set); /* Add listenfd to read set */ while (1) { ready_set = read_set; Select(listenfd+1, &ready_set, NULL, NULL, NULL); /*阻塞进程,直到有描述符可读*/ if (FD_ISSET(STDIN_FILENO, &ready_set)) /*如果可读的是标准输入...*/ command(); if (FD_ISSET(listenfd, &ready_set)) { /*如果可读的是监听描述符...*/ clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); echo(connfd); Close(connfd); } } } void command(void) { char buf[MAXLINE]; if (!Fgets(buf, MAXLINE, stdin)) exit(0); /* EOF */ printf("%s", buf); /* Process the input command */ }
-
基于I/O多路复用的并发事件驱动服务器
I/O多路复用可用作并发事件驱动程序的基础。在事件驱动程序中,某些事件会导致流向前推进。对于这个过程,可以将逻辑流模型化为状态机,如图所示:
也就是说,基于I/O多路复用的服务器只有一个进程!一个事件(请求)对应一次I/O(文件描述符),事件到来时服务器进程提供一次服务,没有事件时服务器进程阻塞!
示例程序#include "csapp.h" /*已连接描述符池*/ typedef struct { /* Represents a pool of connected descriptors */ int maxfd; /* Largest descriptor in read_set */ fd_set read_set; /* Set of all active descriptors */ fd_set ready_set; /* Subset of descriptors ready for reading */ int nready; /* Number of ready descriptors from select */ int maxi; /* Highwater index into client array */ int clientfd[FD_SETSIZE]; /* Set of active descriptors */ rio_t clientrio[FD_SETSIZE]; /* Set of active read buffers */ } pool; /*函数的具体实现见官方代码*/ void init_pool(int listenfd, pool *p); void add_client(int connfd, pool *p); void check_clients(pool *p); int byte_cnt = 0; /* Counts total bytes received by server */ int main(int argc, char **argv) { int listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; static pool pool; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); init_pool(listenfd, &pool); while (1) { /* Wait for listening/connected descriptor(s) to become ready */ pool.ready_set = pool.read_set; pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL); /* If listening descriptor ready, add new client to pool */ if (FD_ISSET(listenfd, &pool.ready_set)) { /*一个新的客户端请求到达,创建连接,放入描述符池*/ clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); add_client(connfd, &pool); } /* Echo a text line from each ready connected descriptor */ check_clients(&pool); /*影响所有准备好的客户端*/ } }
程序中状态机的体现:select函数检测输入事件;add_client函数创建一个新的逻辑流(状态机);check_clients函数会送输入行,从而执行状态转移;而且当客户端完成文本行发送时,它还要删除这个状态机
-
I/O多路复用技术的优劣
优点:比基于进程的设计给了程序员更多对程序行为的控制;运行在单一进程中,共享数据更方便;没有进程切换不需要进程调度更高效
缺点:编码复杂;不能充分利用多核处理器
注:虽然I/O多路复用有缺点,现代高性能服务器(node.js、nginx等)使用的都是基于I/O多路复用的事件驱动编程方式,主要是因为相比于进程和线程的方式,它有明显的性能优势(单核单进程如何体现性能优势???)
12.3 基于线程的并发编程
-
线程
线程由内核自动调度(这里没有考虑用户空间的线程实现),每个线程都有它自己的线程上下文:线程ID、栈、栈指针、程序计数器PC、通用目的寄存器、条件码;所有运行在一个进程里的线程共享该进程的整个虚拟地址空间;同一个进程的多个线程共享进程的栈空间,但是每个线程都是在这个栈中拥有自己私有的栈空间的 -
线程执行模型
1.线程的上下文比进程的上下文小得多,线程切换也更快
2.一个进程内的线程间是对等关系(线程池),而不是父子关系
3.一个线程可以杀死它的任何对等线程或者等待它的任意对等线程终止 -
Posix线程
Posix是一个关于操作系统的标准,书上只是想描述它在线程方面的标准…略 -
创建线程
int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);
参数attr用于新线程的默认属性 -
终止线程
1.void pthread_exit(void *thread_return);
终止当前线程;若当前线程是主线程,则会等待它的所有对等线程终止后,再终止主线程和整个进程
2.调用linux下的exit函数,直接终止整个进程
3.int pthread_cancel(pthread_t tid);
终止当前线程 -
回收已终止线程的资源
int pthread_join(pthread_t tid, void **thread_return);
此函数会阻塞,直到等待的tid线程终止,然后回收已终止线程的所有内存资源 -
分离线程
在任何时刻,线程是可结合的(join) 或者是 可分离的(detach);
可结合的线程:能被其他线程杀死和回收,但是在被其他线程回收之前,它的内存资源比如栈是不释放的
可分离的线程:不能不其他线程杀死和回收,它的内存资源在它终止时由系统自动释放
默认情况下,线程被创建成可结合的。为了避免内存泄漏,每个可结合的线程都应该要么被其他线程显示地回收,要么通过pthread_detach函数被分离int pthread_detach(pthread_t tid);
服务器最好使用分离的线程!!! -
基于线程的并发服务器
#include "csapp.h" void echo(int connfd); void *thread(void *vargp); int main(int argc, char **argv) { int listenfd, *connfdp; socklen_t clientlen; struct sockaddr_storage clientaddr; pthread_t tid; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); while (1) { clientlen=sizeof(struct sockaddr_storage); connfdp = Malloc(sizeof(int)); /*动态内存分配是为了避免对等线程和主线程在连接描述符上的竞争*/ *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); Pthread_create(&tid, NULL, thread, connfdp); } } /* Thread routine */ void *thread(void *vargp) { int connfd = *((int *)vargp); Pthread_detach(pthread_self()); //分离线程,避免内存泄漏 Free(vargp); echo(connfd); Close(connfd); return NULL; }
12.4 多线程程序中的共享变量
-
线程内存模型
线程上下文:每个线程都有它自己独立的线程上下文,包括:线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值
线程共享的部分:每个线程和其他线程一起共享进程上下文的剩余部分,包括用户虚拟地址空间、打开文件集合等(注:同一个进程的多个线程共享进程的栈空间,但每个线程都在这个栈中拥有自己私有的栈空间)
关于线程的私有栈:线程的私有栈共存与进程的栈空间中,通常是被各线程独立访问,但是线程栈不对其他线程设防 => 如果一个线程得到一个指向其他线程栈空间的指针,那它可以读写这个栈的任意部分!!!如程序所示:#include "csapp.h" #define N 2 void *thread(void *vargp); char **ptr; /* Global variable */ int main() { int i; pthread_t tid; char *msgs[N] = { "Hello from foo", "Hello from bar" }; ptr = msgs; /*msgs是主线程中的变量,通过全局的ptr指针,对等线程可以直接访问主线程变量中的内容msgs*/ for (i = 0; i < N; i++) Pthread_create(&tid, NULL, thread, (void *)i); Pthread_exit(NULL); } void *thread(void *vargp) { int myid = (int)vargp; static int cnt = 0; printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt); return NULL; }
-
将变量映射到内存
全局变量:全局变量是定义在函数之外的变量,存储在进程虚拟内存的数据段,只有一个实例,任何线程都可以引用
本地自动变量:每个线程的栈都包含它自己所有本地自动变量的实例,即使多个线程执行同一个线程例程时也是如此
本地静态变量:同全局变量… -
共享变量
我们说一个变量 v v v是共享的,当且仅当它的一个实例被一个以上的线程引用;
全局变量和本地静态变量毫无疑问都是可以被共享的(虽然不一真正被多个线程使用);
容易忽略的是,本地自动变量仍然有可能被线程共享,如上面程序的msgs!!!
12.5 用信号量同步线程
-
概述
共享变量十分方便,但是也引入了同步错误 => 交替执行的线程修改同一个变量,最终运行的结果变得不确定
用信号量可以解决同步错误的问题 -
进度图
合法的转换只能向右或向上;(注意,进度图不适用于多cpu环境)
临界区:对于一个线程,操作共享变量的指令构成了关于这个变量的临界区
互斥:需要确保每个线程在执行其临界区中的代码时,它拥有对共享变量的互斥访问
不安全区域:进度图中,两个临界区的交集形成的状态空间区域称为不安全区,如图所示(假设<L,U,S>指令构成临界区):
-
信号量
关于信号量的细节可参考操作系统书籍,这里介绍posix的信号量函数:
int sem_init(sem_t *sem,0,unsigned int value);
int sem_wait(sem_t *s);
int sem_post(sem_t *s);
思考:对信号量的操作如何保证原子性? -
使用信号量来实现互斥
基本思想是:将每个共享变量与一个信号量联系起来,然后用P、V操作将相应的临界区包围起来
互斥锁:以提供互斥为目的的二元信号量(互斥信号量的值只取0、1)常称为互斥锁,对应的P操作称为加锁、V操作称为解锁
用信号量完成互斥如图所示:
-
利用信号量来调度共享资源(同步)
(这里只是简述,详细的翻操作系统书籍)
1.生产者-消费者问题 => 需要保证两个问题:互斥与同步
2.读者-写者问题 => 写者必须拥有对对象的独占访问;而读者可以和无限多个其他读者共享对象 -
综合:基于预线程化的并发服务器
预线程化:个人理解就是类似于使用线程池,而不是每次创建新的线程!!
预线程化的服务器可以使用下图所示的生产者-消费者(1;N)模型 => 主线程作为生产者,接受来自客户端的请求,并将生产的连接描述符放入一个有限缓冲区;多个工作线程作为消费者,反复从缓冲区中取出连接描述符,提供服务
服务端代码:#include "csapp.h" #include "sbuf.h" #define NTHREADS 4 #define SBUFSIZE 16 void echo_cnt(int connfd); void *thread(void *vargp); sbuf_t sbuf; /* Shared buffer of connected descriptors */ int main(int argc, char **argv) { int i, listenfd, connfd; socklen_t clientlen; struct sockaddr_storage clientaddr; pthread_t tid; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } listenfd = Open_listenfd(argv[1]); sbuf_init(&sbuf, SBUFSIZE); /*初始化共享的连接描述符缓冲区*/ /*预线程化 => 线程个数是固定的,且服务完一个请求后并不会释放线程,而是留着继续服务下一个请求*/ for (i = 0; i < NTHREADS; i++) /* Create worker threads */ Pthread_create(&tid, NULL, thread, NULL); while (1) { clientlen = sizeof(struct sockaddr_storage); connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen); sbuf_insert(&sbuf, connfd); /* Insert connfd in buffer => 已连接描述符加入缓冲区 */ } } void *thread(void *vargp) { Pthread_detach(pthread_self()); while (1) { int connfd = sbuf_remove(&sbuf); /* Remove connfd from buffer */ echo_cnt(connfd); /* Service client */ Close(connfd); } }
由代码可知,基于预线程化的并发服务器实际上也是事件驱动的程序,也是状态机!!
12.6 使用线程提高并行性
- 概述
(本节并无多少新鲜内容,只做简要概述)
1.对于一个进程的多个线程,他们可以同时在多核上执行!因为系统调度的单位线程而不是进程!
2.对于多个核上并行且共享了变量的线程,线程间共享变量的同步开销是巨大的,需要尽可能避免;如果无可避免,必须用尽可能多的有用计算弥补这个开销
3.并行程序常被写为每个核上只运行一个线程(否则单个核上线程上线文切换增大了开销)
12.7 其他并发问题(重要)
12.7.1 线程安全
-
线程安全的定义
一个函数被称为线程安全的,当且仅当被多个并发线程反复调用时,它会一直产生正确的结果 -
线程不安全的函数
1.不保护共享变量的函数
2.保持跨越多个调用的状态的函数
如图所示,伪随机数生成器就是线程不安全的函数!! 因为它的结果依赖于前次调用的中间结果(全局变量next_seed); => 若在单线程中反复调用rand,能预期得到可重复的随机数组序列;若多线程下调用rand,这种假设就不再成立unsigned next_seed = 1; /* rand - return pseudorandom integer in the range 0..32767 */ unsigned rand(void){ next_seed = next_seed*1103515245 + 12543; return (unsigned)(next_seed>>16) % 32768; } /* srand - set the initial seed for rand() */ void srand(unsigned new_seed){ next_seed = new_seed; }
3.返回指向静态变量的指针的函数
=> 因为正在被一个线程使用的结果会被另一个线程悄悄覆盖(因为都是在该指针指向的位置);解决方法:加锁-复制char *ctime_ts(const time_t *timep, char *privatep){ char *sharedp; /*加锁-复制*/ P(&mutex); sharedp = ctime(timep); strcpy(privatep, sharedp); /* Copy string from shared to private */ V(&mutex); return privatep; }
4.调用线程不安全函数的函数
…
小结:全局变量、静态变量、指针都有导致线程不安全的风险!!!
12.7.2 可重入性
- 线程安全与可重入
可重入函数:当他们被多个线程调用时,不会引用任何共享数据(所以一定是线程安全的) => 线程安全和可重入性关系如下:
12.7.3 在线程化的程序中使用已存在的库函数
- 概述
大多数Linux函数以及标准C中的库函数malloc、free、realloc、printf、scanf等都是线程安全的函数
=> 部分线程不安全的函数如下:
12.7.4 竞争
- 竞争
当一个程序的正确性依赖于A线程要在B线程到达其(B)y点之前到达它自己控制流的(A)x点,这就构成了两个线程竞争 => 如图,主线程与其对等线程存在竞争,可能对等线程还未打印,主线程就已经修改了 i i i#include "csapp.h" #define N 4 void *thread(void *vargp); int main() { pthread_t tid[N]; int i; for (i = 0; i < N; i++) //line:conc:race:incri Pthread_create(&tid[i], NULL, thread, &i); //line:conc:race:createthread for (i = 0; i < N; i++) Pthread_join(tid[i], NULL); exit(0); } /* Thread routine */ void *thread(void *vargp) { int myid = *((int *)vargp); //line:conc:race:derefarg printf("Hello from thread %d\n", myid); return NULL; }
12.7.5 死锁
- 死锁
一组线程被阻塞了,等待一个用于也不会为真的条件 - 用进度图理解死锁
=> 导致死锁的原因(之一,如图):P、V操作顺序不当,以至于两个信号量禁止区域重叠;
死锁是一个相当困难的问题,因为它总是不可预测;
避免死锁的规则(适用于二元信号量):给定所有互斥操作一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的!
12.8 小结
-
较大收获/易忘易混
1.理解I/O多路复用的原理
2.线程的可结合和分离
3.用进度图理解不安全区域
4.理解互斥与同步的关系
5.理解互斥信号量实际上就是互斥锁
6.理解线程安全;知道哪些函数是线程不安全的函数
7.全局变量、静态变量、指针都有导致线程不安全的风险!!!
8.理解什么是可重入性
9.用进度图理解死锁,以及导致死锁的原因 -
疑惑
1.如何理解I/O多路复用比进程、线程有明显的性能优势(P689如是说)?
2.线程、进程执行完自动退出时,是否已经释放了资源? => 应该是执行完自动退出会有系统释放其资源;否则不会释放资源
3.对信号量的操作函数sem_wait、sem_post如何保证原子性?