深入理解计算机系统—阅读摘要

系统级IO

每个进程都有个umask,通过umask函数来设置的,当进程通过带某个mode参数的open函数来创建一个新文件时,文件的访问权限位被设置为mode & ~umask,也就是umask是程序设定的掩码,哪怕你open时mode为777,最后出来的权限有可能不是777了。

共享文件:

  • 每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
  • 打开文件的集合是由一张文件表来表示的,所有进程共享这张表。文件表表项包括当前的文件位置、引用计数即当前指向该表项的描述符表项数,以及一个指向v-node表中对应表项的指针。引用计数变为0内核才会删除这个文件表项。
  • 所有进程共享v-node表。表项包含stat结构中的大部分信息,如st_mode、st_size成员。
    描述符1/4打开不同的文件,有不同的文件表项,以及相对应的v-node:

    子进程有父进程描述符的副本,父子进程共享相同的文件表,所以共享相同的文件位置,另外,内核删除相应文件表表项之前,父子进程都必须关闭了它们的文件描述符。

    同一个文件open两次,也有不同的文件表项,记录自己的文件位置,但v-node是同一个,这种属于文件共享:

IO重定向:

1
2
#include <unistd.h>
int dup2(int oldfd, int newfd);

dup2函数拷贝描述符表项oldfd到描述符表表项newfd,覆盖描述符表表项newfd以前的内容。如果newfd已经打开了,dup2会在拷贝oldfd之前关闭newfd。下图描述符1(标准输出)对应文件A(比如是一个终端),描述符4对应文件B(比如是一个磁盘文件),最开始时A和B的引用计数都为1,调用dup2(4,1)后,两个描述符都指向B,也就是第一个参数是重定向的目的地,文件A已经被关闭了,不再有标准输出了,并且它的文件表和v-node表表项也已经删除了。文件B(目的地那个)的引用计数已经增加了,以后写到标准输出的数据都被重定向到文件B。

例子:foobar.txt里面的内容是foobar,该例子输出为o。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "csapp.h"

int main()
{
int fd1, fd2;
char c;

fd1 = Open("foobar.txt", O_RDONLY, 0);
fd2 = Open("foobar.txt", O_RDONLY, 0);
Read(fd2, &c, 1);
Dup2(fd2, fd1);
Read(fd1, &c, 1); // fd1已经重定向到fd2了,文件表表项也是用的fd2的了,所以位置也是在fd2的基础上进行读写
printf("c = %c\n", c);
exit(0);
}

标准IO:
ANSI C定义了一组高级的输入输出函数,称为标准IO库,为程序员提供UNIX IO的较高级别的接口。文件相关的fopen/fclose、读写字节fread/fwrite、读写字符串fgets/fputs、格式化io scanf/printf。标准IO库将一个打开的文件模型化为一个流。一个流就是一个执行类型FILE结构的指针。FILE流是对文件描述符和流缓冲区的抽象,使开销较高的Unix IO系统调用的次数尽可能的少。简单来说,:只要缓冲区有未读的字节,对getc的标准io调用就能直接从流缓冲区中得到服务。

网络编程

一个网络主机的硬件组成。

桥接以太网。

数值0x01234567使用两个字节储存:高位字节是0x01,低位字节是0x67。
大端字节序:高位字节在前,低位字节在后,这是人类读写数值的方法。大对应高。
小端字节序:低位字节在前,高位字节在后,即以0x67452301形式储存。

网络字节序和主机字节序转换方法:

1
2
3
4
5
6
7
#include <netinet/in.h>

unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);

unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);

ip地址与点分十进制串之间的转换:

1
2
3
4
5
#include <arpa/inet.h>

// n:network, a:application, to:转换
int inet_aton(const char *cp, struct in_addr *inp); // 点分十进制cp转为网络字节序ip
char *inet_ntoa(struct in_addr in); // 网络字节序ip转为点分十进制串

可以调用gethostbyname和gethostbyaddr函数,从DNS数据库中检索任意的主机条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct hostent {
char *h_name; /* official name of host */
char **h_aliases; /* alias list */
int h_addrtype; /* host address type */
int h_length; /* length of address */
char **h_addr_list; /* list of addresses */
}

#include <netdb.h>

struct hostent *gethostbyname(const char *name);
struct hostent *gethostbyaddr(const void *addr, socklen_t len, int type); // type一般为0

// The gethostbyname*() and gethostbyaddr*() functions are obsolete. Applications should use getaddrinfo(3) and getnameinfo(3) instead.

网络套接字接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ telnet nephen.cn 80
Trying 110.40.194.189...
Connected to nephen.cn.
Escape character is '^]'.
GET / HTTP/1.1
Host: nephen.cn

HTTP/1.1 301 Moved Permanently
Server: nginx/1.21.6
Date: Thu, 16 Feb 2023 09:43:02 GMT
Content-Type: text/html
Content-Length: 169
Connection: keep-alive
Location: https://nephen.cn/

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx/1.21.6</center>
</body>
</html>

代理缓存中会使用Host报头,指示了原始服务器的域名。响应报头重要的是Content-Type以及Content-Length。

CGI服务器
服务器收到GET /cgi-bin/adder?15000&213 HTTP/1.1时,会调用fork来创建一个子进程,并调用execve在子进程中的上下文中执行/cgi-bin/adder程序,adder这样的程序就是CGI程序,在调用execve前,子进程将环境变量QUERY_STRING设置为15000&213,adder程序运行时可以通过Unix getenv函数获得它。
CGI程序将内容发送到标准输出,因为在子进程加载并运行CGI程序前,服务器使用Unix dup2函数将标准输出重定向到与客户端相关联的已连接描述符(所以要新建一个子进程,不能直接在父进程中进行,因为会有很多已连接描述符)。因此,任何CGI程序写到标准输出的东西都会写到客户端。对于POST请求,子进程也需要重定向标准输入到已连接描述符,这样CGI程序可以从标准输入中读入请求主体中的参数。

1
2
3
4
5
6
if (fork() == 0) {
setenv("QUERY_STRING", "15000&213", 1);
dup2(fd, STDOUT_FILNO);
execve(filename, emptylist, environ);
}
wait(NULL);

Nginx不支持对外部程序的直接调用或者解析,所有的外部程序(包括PHP)必须通过FastCGI接口来调用。FastCGI接口在Linux下是socket,(这个socket可以是文件socket,也可以是ip socket)。为了调用CGI程序,还需要一个FastCGI的wrapper(wrapper可以理解为用于启动另一个程序的代理服务程序),这个wrapper绑定在某个固定socket上,如端口或者文件socket。当Nginx将CGI请求发送给这个socket的时候,通过FastCGI接口,wrapper代理服务程序接纳到请求,然后派生出一个新的线程,这个线程调用解释器或者外部程序处理脚本并读取返回数据;接着wrapper代理服务程序再将返回的数据通过FastCGI接口,沿着固定的socket传递给Nginx;最后,Nginx将返回的数据发送给客户端,这就是Nginx+FastCGI的整个运作过程。详细的过程,如图所示

与为每个请求创建一个新的进程不同,FastCGI使用持续的进程来处理一连串的请求。这些进程由FastCGI进程管理器管理,而不是web服务器。

1
2
3
4
5
6
location ~* \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; #文档路径
fastcgi_param PATH_INFO $fastcgi_script_name; # 脚本名
include fastcgi_params;
}

并发编程

基于进程的并发服务器:子进程需要关闭它的监听描述符3,因为父子进程的已连接描述符都指向同一个文件表表项,父进程需要关闭它的已连接描述符4,否则永不会释放已连接描述符4的文件表条目。
共享文件表,但不共享用户地址空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void sigchld_handler(int sig)
{
while(waitpid(-1, 0, WNOHANG) > 0);
return;
}

Signal(SIGCHLD, sigchld_handler);
while (1) {
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
if (Fork() == 0) {
Close(listenfd);
echo(connfd); // do something
Close(connfd);
exit(0);
}
Close(connfd);
}

IO多路复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
FD_SET(STDIN_FILENO, &read_set);
FD_SET(listenfd, &read_set);

while (1) {
ready_set = read_set;
Select(listenfd+1, &ready_set, NULL, NULL, NULL); // ready_set会被修改,所以每次都要重新传入
if (FD_ISSET(STDIN_FILENO, &ready_set))
command();
if (FD_ISSET(listenfd, &ready_set)) {
connfd = Accept(listenfd, (SA*)&clientaddr, &clientlen);
echo(connfd);
}
}

更细粒度的多路复用(有限状态机模型):单一进程,某个逻辑流阻塞,其他流就不可能有进展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
typedef struct {
int maxfd;
fd_set read_set; // 输入值,作为备份
fd_set ready_set; // 输出值,会被更改
int nready;
int maxi;
int clientfd[FD_SETSIZE]; // 记录槽位是否可用
rio_t clientrio[FD_SETSIZE];
} pool;

listenfd = open_lestenfd(port);
init_pool(listenfd, &pool);
while (1) {
pool.ready_set = pool.read_set; // 每次都需要复制
pool.nready = Select(pool.maxfd+1, &pool.ready_set, NULL, NULL, NULL);

if (FD_ISSET(listenfd, &pool.ready_set)) { // 判断释放触发事件了
connfd = Accept(listenfd, (SA*)&clientaddr, &clientlen);
add_client(connfd, &pool);
}

// 处理事件
check_clients(&pool);
}

void init_pool(int listenfd, pool *p)
{
p->maxi = -1;
for (int i = 0; i < FD_SETSIZE; i++) {
p->clientfd[i] = -1; // -1表示一个可用的槽位
}
p->maxfd = listenfd;
FD_ZERO(&pool.read_set);
FD_SET(listenfd, &p->read_set); // 监听描述符是唯一的描述符
}

// 创建新的逻辑流,状态机
void add_client(int connfd, pool *p)
{
p->nready--;
for (int i = 0; i < FD_SETSIZE; i++) {
if (p->clientfd[i] < 0) { // 有槽位可用
p->clientfd[i] = connfd;
Rio_readinitb(&p->clientrio[i], connfd);
FD_SET(connfd, &p->read_set); // 已连接文件描述符添加进去
if (connfd > p->maxfd) {
p->maxfd = connfd; // 记录最大的文件描述符
}
if (i > p->maxi) {
p->maxi = i; // 记录最大索引,这样check_clients就不需要搜索整个数组了
}
break;
}
}
if (i == FD_SETSIZE) { // select有最大值限定
app_error("add_client error: Too many clients");
}
}

// 状态转移
void check_clients(pool *p)
{
int connfd, n;
char buf[MAXLINE];
rio_t rio;

for (int i = 0; (i <= p->maxi) && (p->nready > 0); i++) { // 遍历到maxi就行了
connfd = p->clientfd[i];
rio = p->clientrio[i];
if ((connfd > 0) && (FD_ISSET(connfd, &p->ready_set))) {
p->nready--; // 处理完了要减1
if ((n = Rio_readline(&rio, buf, MAXLINE)) != 0) {
byte_cnt += n;
printf("Server received %d (%d total) bytes on fd %d\n", n, byte_cnt, connfd);
Rio_write(connfd, buf, n); // 回复
}
} else {
Close(connfd); // 先close
FD_CLR(connfd, &p->read_set); // 准备的这个set也要清空
p->clients[i] = -1; // 槽位清空
}
}
}

基于线程并发编程:
一个分离的线程是不能被其他线程回收或杀死的。它的存储器资源在它终止时由系统自动释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
listenfd = open_listenfd(port);
while (1) {
connfdp = Malloc(sizeof(int)); // 这里需要分配在堆上,不然下次accept会把栈上的覆盖掉
*connfdp = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Pthread_create(&tid, NULL, thread, connfdp);
}

void *thread(void *vargp)
{
int connfd = *(int *)vargp;
Pthread_detach(pthread_self());
Free(vargp);
echo(connfd);
Close(connfd);
return NULL;
}

posix标准定义了许多操作信号量的函数。三个基本的操作是sem_init、sem_wait(P操作)和sem_post(V操作),P(Prtoberen测试)/V(Verhogen增加)。

1
2
3
4
5
sem_t mutex;
sem_init(&mutex, 0, 1);
P(&mutex);
Cnt++;
V(&mutext);

如果对于程序中每对互斥锁(s,t),每个既包含s也包含t的线程都按相同的顺序同时对它们加锁,那么这个程序是无死锁的。

nephen wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
坚持原创技术分享,您的支持将鼓励我继续创作!