webserver

网络基础

各种协议的端口可以在/etc/services中查看,对应的protocol(IP头中的协议字段)在/etc/protocols。
常用检错方法:

  • CRC 循环冗余校验,用于以太网帧
  • Checksum 检验和,双字节反码求和,回卷溢出(低位+1),最后取反,用于IP数据报(IP首部)、TCP报文段(覆盖伪首部,首部和数据)、UDP数据报(覆盖伪首部,首部和数据)、ICMP报文(首部,数据)

伪首部包括了源IP地址、目的IP地址、协议类型、TCP/UDP报文长度,用于计算校验和,共12字节。

ICMP头包括类型、代码(二者组合描述具体的报文类型),校验码(CRC)等共8字节,主要用于检测网络连接。

以太网帧头14字节,包括目的MAC地址、源MAC地址、协议类型。数据之后为CRC 4字节。
ARP头部28字节,包括硬件类型、协议类型、硬件地址长度、协议地址长度、操作码、发送方以太网地址、发送方IP地址、接收方以太网地址、接收方IP地址。使用时直接封装在以太网帧中。
DNS为域名查询服务,DNS服务器的IP地址可以在/etc/resolv.conf中查看,使用host -t A www.baidu.com可以通过访问DNS服务器查询域名对应的IP地址。

IP协议提供无状态(通信双方不同步传输数据的状态信息)、无连接、不可靠的服务,上层协议如TCP提供面向连接的协议。
IP头里的一些重要字段

  • 头部长度 4位,以4字节为单位,因此IP头部最大为60字节
  • TOS 服务类型,用于区分不同的服务,如最大吞吐量、最小延迟、最高可靠性和最小费用等
  • 总长度 16位,包括首部和数据部分的长度
  • 标识 16位,用于唯一标识主机发送的每一份数据报,同一数据报的分片具有相同的标识
  • DF 1位,1表示不分片,0表示可以分片
  • MF 1位,1表示还有分片,0表示最后一片
  • 偏移 13位,指明该片相对于原始数据报开头的偏移量,以8字节为单位
  • TTL 8位,生存时间,每经过一个路由器减1,为0时丢弃
  • procotol 8位,上层协议,如TCP、UDP、ICMP

使用route查看路由表,使用route add添加路由,使用route del删除路由。对于大型路由器通常使用BGP、RIP和OSPF等协议来动态更新路由表。

TCP

基于流:数据没有长度限制,不断从通信一端流向另一端,发送端逐字节地向数据流中写入,接收端逐字节读出,如TCP。
基于数据报:数据报都有长度,接收端以该长度为单位将内容一次性读出,如UDP。
bytestream

TCP三次握手

  • 发送方初始化序列号ISN1,发送包含SYN标志的报文
  • 接收方收到后,初始化序列号ISN2,发送包含SYN和ACK标志的报文。缓存和变量在此时分配。
  • 发送方收到后,发送包含ACK标志的报文,连接已经建立。可以在这个报文里发送数据。

TCP四次挥手

  • 发送方发送包含FIN标志的报文,请求关闭连接
  • 接收方收到后,发送包含ACK标志的报文,确认收到FIN报文
  • 接收方发送包含FIN标志的报文,请求关闭连接
  • 发送方收到后,发送包含ACK标志的报文,确认收到FIN报文,连接关闭

第二步可以省略,因为第三步的ACK报文也可以确认收到FIN报文。是否省略和延迟确认有关。
tcpinit
需要注意的是SYN和FIN报文不携带数据,但是会占用序列号。这点CS144实验里也做过。

半关闭
TCP是全双工的,可以同时发送和接收数据,允许两个方向的数据流独立关闭,即半关闭。一方可以先发送FIN报文,表明已完成数据传送,但仍可以接收数据。
halfclose
应用程序判断关闭的方法是read系统调用返回0

使用测试程序以及netstat命令可以看到连接关闭过程
服务器运行程序后,使用telnet进行连接
如果服务器使用accept创建了已连接描述符,那么服务器对这个描述符调用close,会使客户-服务器的连接关闭,而服务器-客户的连接(即主动关闭方)进入TIME_WAIT状态,直到2MSL后关闭。
如果服务器没有使用accept创建已连接描述符,而是只用了bind和listen,关闭客户进程会使客户-服务器的连接进入FIN_WAIT2,而服务器-客户的连接进入CLOSE_WAIT。此后关闭服务器则连接直接关闭,不会进入TIME_WAIT状态。

TCP状态转换图为
tcpstate
粗实线表示客户端连接的状态转移,粗虚线表示服务器连接的状态转移。
进入TIME_WAIT后并没有立即关闭,而是等待2MSL时间,MSL为报文最大生存时间,一般为2分钟。这是为了可靠地终止TCP连接,并保证迟来的TCP报文有足够时间被识别并丢弃。

复位报文
发送带有RST标志的报文,可以用于拒绝连接或者终止连接。
访问不存在的端口时,服务器会发送RST报文。
异常终止连接时,服务器会发送RST报文。
半打开状态,服务器关闭或异常终止连接,客户端没收到结束报文,仍然维持链接,此时服务重启后,会发送RST报文。

交互数据和成块数据

  • 交互数据传送数据少,实时性要求高。服务器对客户请求处理得很快,发送确认报文时总是有数据一起发送,这种延迟确认方法延后了对报文的确认,减少报文数量。而客户端用户的输入速度慢于处理速度,所以总是单独发送确认报文。

  • 成块数据通常为最大报文长度,传输效率要求高。当传输大量大块数据时,发送方会连续发送多个报文段,接收方可以一次性确认多个报文段。未确认报文段的数量由接收窗口大小和算法的实现决定。发送方还可能发送带有PSH标志的报文段,表示接收方应该立即将数据交给应用程序。

带外数据(OOB Out of Band)
用于迅速通告对方本地发生的重要事件,比普通事件有更高的优先级。可以使用独立的TCP连接,也可以映射到传输普通数据的TCP连接上。
TCP使用紧急标志URG和紧急指针来标识带外数据,紧急指针指向最后一个带外数据字节的下一个字节(因此实际只有最后一个字节被当做真正的带外数据,而之前的字节被当做普通数据)。接收方有个一1字节的带外缓存,用于存放带外数据,如果没有及时读出会被覆盖。

超时重传
TCP维护重传定时器,第一次发送报文时被启动,超时会重传最早未确认报文并重启定时器,收到ACK会重置定时器。一种策略是每次超时时间加倍。
ps:书上说为每个报文段设置重传定时器,感觉有点不可能吧,在网上没有搜到很清晰正确的回答,在CS144实验里只用了一个重传定时器。
/proc/sys/net/ipv4/tcp_retries1,默认为3,即底层IP接管前最少重传次数。
/proc/sys/net/ipv4/tcp_retries2,默认为15,即重传15次后仍未收到确认报文,则放弃连接。

拥塞控制四个算法

  • 慢启动
  • 拥塞避免
  • 快速重传
  • 快速恢复

接收窗口rwnd,发送窗口swnd,拥塞窗口cwnd
接收方将rwnd放在TCP的接受窗口字段,通知主机剩余缓存空间,发送方保证未确认数据量不超过rwnd。实际上满足\(swnd=min(cwnd,rwnd)\),发送方的发送窗口是拥塞窗口和接收窗口的最小值。

慢启动:TCP连接建立时,cwnd初始化为MSS(也可能是4MSS,MSS典型值为MTU-40),每次收到确认,重新计算\(cwnd+=min(N,MSS)\),这种增长对RTT是指数级的。为了结束增长设置了慢启动阈值ssthresh,初始为MSS的整数倍。
结束慢启动的方式有

  • 出现超时,cwnd置为MSS,重新开始慢启动,并将ssthresh置为当前cwnd的一半。
  • cwnd值达到ssthresh时,进入拥塞避免状态。
  • 收到3次冗余ACK,sshtresh置为当前cwnd的一半,cwnd置为sshtresh+3MSS,快速重传,进入快速恢复状态。

拥塞避免:使cwnd线性增长

  • 不管收到多少个确认,cwnd每个RTT增加MSS
  • 每收到确认,cwnd增加MSS*MSS/cwnd
    结束线性增长的方式
  • 出现超时,cwnd置为MSS,ssthresh置为当前cwnd的一半,进入慢启动状态
  • 收到3次冗余ACK,sshtresh置为当前cwnd的一半,cwnd置为sshtresh+3MSS,快速重传,进入快速恢复状态。

拥塞恢复

  • 每次收到冗余ACK,cwnd增加MSS,直到收到新的ACK,进入拥塞避免状态。
  • 出现超时,cwnd置为MSS,ssthresh置为当前cwnd的一半,进入慢启动状态

快速重传:收到3次冗余ACK,说明之后的报文段丢失,立即重传,不等待超时。

废话好多还是上图吧
congesttion

/proc/sys/net/ipv4/tcp_congestion_control,可以查看当前使用的拥塞控制算法。

Web服务器

正向代理:客户端自己设置代理服务器地址,代理服务器代替客户端请求目标资源。比如FQ。
反向代理:客户端不知道代理服务器的存在,反而被设置在服务器端,代理服务器接受请求并访问内部网络的服务器资源,将结果返回给客户端,表现得像服务器一样。比如负载均衡,公司会设置很多代理服务器,域名具有不同的IP地址。
透明代理:设置在网关上,可以看作是正向代理的特殊情况。
proxy

HTTP
请求行包括请求方法,URL,版本。
requestline
头部的Connecion字段决定采用什么连接方式

  • close 短连接,一个TCP连接只用于一个HTTP请求,请求完成后连接断开。
  • keep alive 长连接,多个请求可以使用相同的TCP连接。

HTTP是一种无状态协议,使用Cookie保持连接状态。Cookie是服务器发给客户端的信息,客户端每次请求时都带上信息,服务器就可以区分用户了,并且可以减少信息重传。

API

连接管理api

主机序通常为小端,网络序为大端。
由于没有设计时C还没有void*,因此在定义套接字函数api时需要一个通用的sockaddr结构,将与协议特定的结构体指针强制转换为sockaddr指针,这样就可以使用同一个函数处理不同的协议了。至于sockaddr的sa_data为什么是14字节,可能是历史遗留问题,其设计比sockaddr_in更早。

// IPv4地址结构
struct in_addr{
    uint32_t s_addr;
};

struct sockaddr_in{
	uint16_t sin_family; //AF_INET
	uint16_t sin_port; //16位的端口号
	struct in_addr sin_addr; //32位的IP地址
	unsigned char sin_zero[8]; //对齐,使和sockaddr结构体一样大
}

//用于提供一个指向与协议相关的套接字地址结构的指针,通过强制类型转换,
//实现类似于void*的功能。
struct sockaddr{ 
	uint16_t sa_family; //16bit,2byte
	char sa_data[14]; //112bit,14byte
};
//example:
typedef struct sockaddr SA;
//_in后缀不是input的缩写,而是internet的缩写。

地址族:AF_INET、AF_INET6、AF_UNIX等
协议族:PF_INET、PF_INET6、PF_UNIX等
以上对应真值相等,但是在实际使用中,一般用PF
服务类型:SOCK_STREAM、SOCK_DGRAM、SOCK_RAW等
proctocal参数为0时,会根据地址族和服务类型自动选择协议族,通常是唯一的。

一个套接字是连接的一个端点,套接字地址包括IP和端口,连接由两端套接字地址共同确定。

创建一个socket

int socket(int domain, int type, int protocol);
成功返回一个非负整数fd,失败返回-1并设置errno

绑定一个socket,将一个socket和一个socket地址绑定,即给socket命名。
通常在服务器命名,这样客户端才知道如何连接,而客户端不需要,采用匿名方式,系统自动分配socket地址。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

成功时返回0,失败时返回-1并设置errno,常见的错误

  • EACCES 权限不足,通常是试图绑定低于1024的端口号,只有root用户才能绑定
  • EADDRINUSE 地址已经被使用,通常是服务器重启后,因为TIME_WAIT状态,导致地址被占用,可以设置SO_REUSEADDR选项来避免服务器在重启的大约30秒内拒绝连接。

int listen(int sockfd, int backlog);

创建监听队列以存放待处理的客户连接,其中backlog指定队列的最大长度,超过的连接会被拒绝,实际linux2.2之前指所有处于版链接状态(SYN_RCVD)和完全连接状态的socket(ESTAVLISHED)的上限,之后指标是完全处于连接状态(ESTAVLISHED)的socket上限,超出的连接在客户端可以看到处于SYN_SEND状态。
同样成功返回0,失败返回-1并设置errno。
测试程序backlog.cpp的输出结果

tcp        0      0 127.0.0.1:12345         127.0.0.1:38846         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:38848         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:38850         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:38852         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:38870         ESTABLISHED
tcp        0      0 127.0.0.1:12345         127.0.0.1:38872         ESTABLISHED
tcp        0      0 127.0.0.1:38846         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:38848         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:38850         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:38852         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:38870         127.0.0.1:12345         ESTABLISHED
tcp        0      0 127.0.0.1:38872         127.0.0.1:12345         ESTABLISHED
tcp        0      1 127.0.0.1:38874         127.0.0.1:12345         SYN_SENT   
tcp        0      1 127.0.0.1:38876         127.0.0.1:12345         SYN_SENT   

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

创建一个已连接套接字,从监听队列中取出一个连接(不管连接状态和网络状况),如果队列为空,则阻塞,直到队列中有连接。并将远程套接字信息存入addr指向的空间,addrlen由函数调用者填充。为什么第三个参数是指针?因为不仅需要在调用时填充,函数内部会修改其值,以便返回实际的地址长度。
侦听套接字用于建立连接,已连接套接字用于数据传输。貌似侦听套接字由一个套接字地址标志,但是已连接套接字是由侦听套接字和客户端套接字地址共同确定的(此处暂且不谈协议),也就是说不同的连接可以使用相同的端口?因为客户端套接字地址不同?

实际上存在半连接队列,当服务器收到SYN报文时,会将其放入半连接队列,当收到ACK报文时,会将其放入已连接队列。因此半连接队列的长度backlog+1,因为backlog指已连接队列的长度。
真正的连接是通过connect函数建立的,而accept只是从已连接队列中取出一个连接。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端主动发起连接,addr为服务器套接字地址,addrlen为地址长度,成功返回0,失败返回-1并设置errno。
ECONNREFUSED 服务器拒绝连接,目标端口不存在。
ETIMEDOUT 连接超时,服务器没有响应。

int close(int fd);

关闭连接就是关闭套接字(实际上是操作引用计数),成功返回0,失败返回-1并设置errno。

int shutdown(int sockfd, int how);

关闭连接而不是操作引用计数,how为SHUT_RD、SHUT_WR、SHUT_RDWR,分别表示关闭读、写、读写。

数据读写api

文件读写的read和write同样可用,但专用的读写函数提供了对读写的控制。比如csapp中的rio包解决了网络读写由于网络波动和缓冲出现不足值的问题,往往内部通过反复调用read和write处理不足值。

TCP读写

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

recv读取socket数据,但读取长度可能小于期望,可能需要多次调用,返回值为0表明对方关闭连接,返回-1表明出错。
send发送socket数据,返回实际写入数据的长度,返回-1表明出错。
flags控制,只对当前调用生效

  • MSG_PEEK 从缓冲区读取数据,但不清除缓冲区
  • MSG_DONTWAIT 操作非阻塞
  • MSG_WAITALL 读取len个字节后才返回
  • MSG_CONFIRM 用于UDP,持续监听对方回应,直到收到答复
  • MSG_DONTROUTE 不查看路由表,直接发送到本局域网内的主机
  • MSG_OOB 读取带外数据(紧急数据)

带外数据的例子

发送123 abc(oob) 123
接收方读取三次得到123ab c 123

同时说明了对正常数据的接受会被带外数据截断。

UDP读写

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

UDP是无连接的,因此每次读写都需要指定目标地址,而TCP是有连接的,因此只需要在连接建立时指定一次即可。
注意最后一个参数recvfrom是指针,因为需要返回实际的地址长度,而sendto是值,因为不需要返回实际的地址长度。
其实也可以用于stream的数据读写,只需要把后两个参数置为NULL。

通用读写

ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr *msg, int flags);

结构体为

struct msghdr{
	void *msg_name; //套接字地址
	socklen_t msg_namelen; //套接字地址长度
	struct iovec *msg_iov; //数据缓冲区
	int msg_iovlen; //数据缓冲区长度
	void *msg_control; //辅助数据
	socklen_t msg_controllen; //辅助数据长度
	int msg_flags; //标志
}

struct iovec{
	void *iov_base; //数据缓冲区
	size_t iov_len; //数据缓冲区长度
}

对于sendmsg来说,iovlen个分散内存块的数据将被一并发送,成为几种写。
对于recvmsg来说,数据块将被读取并存放在分散内存中,msg_iov指向存放iovec的数组,位置和长度由数组内容指定,这被称为分散读。
msg_flags与函数中的flags含义和值相同。

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

通常我们不知道带外数据何时到来,但是内核可以检测到带外数据到达:通常使用I/O服用产生的异常事件和SIGURG信号。

int sockatmark(int sockfd);

判断套接字是否处于带外标记处,返回1表示处于,返回0表示不处于,返回-1表示出错。在带外标记上强制停止读操作的做法使得进程能够调用sockatmark确实缓冲区指针是否处于带外标记。

getsockname(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
getpeername(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

分别将本地和远程套接字地址存入addr指向的空间。

设置socket选项

getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

level指定协议选项,optname指定选项名,optval指定选项值,optlen指定选项长度。
sockopt
服务器部分选项需要在调用listen之前对监听套接字设置,accept返回的连接套接字会继承这些选项。包括SO_DEBUG,SO_DONTROUTE,SO_KEEPALIVE,SO_LINGER,SO_OOBINLINE,SO_RCVBUF,SO_SNDBUF,SO_RCVLOWAT,SO_SNDLOWAT,TCP_NODELAY,TCP_MAXSEG。而客户需要在调用connect之前设置。

SO_REUSEADDR选项
服务器设置可以强制使用TIME_WAIT状态的地址,这样服务器重启后不会出现地址被占用的情况。

SO_RCVBUF和SO_SNDBUF选项
分别表示了接收缓冲区和发送缓冲区的大小。但设置时系统都是将值加倍,并且接受最小值为256字节,发送最小值为2048字节。

SO_RCVLOWAT和SO_SNDLOWAT选项
分别表示了接收缓冲区和发送缓冲区的低水位标记,当缓冲区中的数据量低于低水位标记时,将阻塞读写操作。默认为1,即只要缓冲区中有数据就不会阻塞。

SO_LINGER选项
默认情况下使用close关闭socket后,close立即返回,TCP模块负责发送缓冲区中残留数据。

struct linger{
	int l_onoff; //0表示关闭,非0表示打开
	int l_linger; //滞留时间
}
  • l_onoff为0时,l_linger无意义,此时即默认行为
  • l_onoff为非0时,l_linger为0,close立即返回,TCP模块丢弃缓冲区中残留数据,并发送复位报文RST给对方。
  • l_onoff为非0时,l_linger为非0。若阻塞,则等待l_linger时间,直到发送完残留数据并得到确认;如果这段时间内未发送完并得到确认则返回-1并设置errno为EWOULDBLOCK。若非阻塞,close立即返回,根据返回值和errno判断是否发送完毕。

网络信息api

struct hostent gethostbyname(const char *hostname);
struct hostent gethostbyaddr(const void *addr, socklen_t len, int type);

name指定主机名,addr指定主机ip地址,len指定地址长度,type指定地址类型。

struct hostent{
	char *h_name; //主机名
	char **h_aliases; //主机别名列表
	int h_addrtype; //地址类型
	int h_length; //地址长度
	char **h_addr_list; //主机ip地址列表
}

实际上通过读取/etc/hosts文件来获取主机名和ip地址,如果没有则通过DNS服务器来获取。

struct servent *getservbyname(const char *name, const char *proto);
struct servent *getservbyport(int port, const char *proto);

name指定服务名,proto指定服务类型("tcp"/"udp"),port指定端口号。

struct servent{
	char *s_name; //服务名
	char **s_aliases; //服务别名列表
	int s_port; //端口号
	char *s_proto; //服务类型
}

实际上通过读取/etc/services文件来获取服务名和端口号。

以上四个函数都是线程不安全的,均不可重入,因为返回的结构体指针指向静态内存。

int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res);

hostname为主机名或ip地址(点分十进制),service为服务名或端口号(十进制),hints为addrinfo结构体,用于设置查询信息,res为addrinfo结构体指针的指针,用于存放结果。该函数可以通过主机名获得ip地址,也可以通过服务名获得端口号。

struct addrinfo{
	int ai_flags; //AI_PASSIVE, AI_CANONNAME, AI_NUMERICHOST等
	int ai_family; // 地址族AF_INET, AF_INET6, AF_UNIX
	int ai_socktype; //服务类型SOCK_STREAM, SOCK_DGRAM
	int ai_protocol; //协议IPPROTO_TCP, IPPROTO_UDP
	size_t ai_addrlen; //地址长度
	struct sockaddr *ai_addr; //地址
	char *ai_canonname; //主机别名
	struct addrinfo *ai_next; //下一个结果
}

ai_flag
AI_PASSIVE用于服务器,表示返回的地址可被用于监听套接字,即可以用于bind。
hints可以设置前四个字段,其他字段NULL。

该函数使用堆内存,是可重入的,使用完成后需要释放res指向的一系列内存

void freeaddrinfo(struct addrinfo *res);

int getnameinfo(const struct sockaddr *addr, socklen_t addrlen, char *host, socklen_t hostlen, char *serv, socklen_t servlen, int flags);

addr为套接字地址,addrlen为地址长度,host和serv分别为主机名和服务名的缓冲区,hostlen和servlen分别为缓冲区长度。
用于将套接字信息转换为主机名和服务名,也可以通过设置flag为NI_NUMERICHOST和NI_NUMERICSERV获得ip地址和端口号。

char* strerror(int errnum);

将errno转换为字符串错误信息

void perror(const char *s);

输出字符串s,然后输出错误信息。相当于printf("%s: %s\n", s, strerror(errno));

高级IO

管道

int pipe(int pipefd[2]);

将一对打开的文件描述符填入pipefd数组,pipefd[0]用于读,pipefd[1]用于写。通常用于父子进程间通信,父进程关闭pipefd[1],子进程关闭pipefd[0],然后父进程写,子进程读。管道只能用单向通信,若要双向通信则需要连个管道分别用于两个单向的数据流。默认情况下管道是阻塞的,读空阻塞,写满阻塞。直到文件描述符引用计数为0时,对应端关闭,pipefd[1]关闭时,读pipefd[0]读到EOF;pipefd[1]关闭时,写pipefd[1]收到SIGPIPE信号。

int socketpair(int domain, int type, int protocol, int fd[2]);

创建双向管道,前三个参数和socket函数相同,domain只能用AF_UNIX,因为只能在本地使用这个双向管道,任一fd均可读写。

dup和dup2

int dup(int oldfd);

创建新的文件描述符,和oldfd指向同一个文件表项。总是返回当前可用的最小文件描述符。

int dup2(int oldfd, int newfd);

复制表项oldfd到newfd,覆盖newfd以前的内容,如果newfd已经打开,则先关闭newfd;相同则什么也不做。
从表现上看即对newfd的操作实际上是对oldfd的操作。

dup结果是两个文件描述符指向同一个文件表项,文件表项引用计数加1。dup2本质和dup一样,只是指定了newfd。

int splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

用于在两个文件描述符之间移动数据,零拷贝操作。
如果fd_in是管道,则off_in必须为NULL;如果不是管道,那么off_in指示从何处开始读取数据,为NULL则从当前位置开始读取,否则指出偏移位置。len指定要移动的字节数,flags指定操作方式。fd_in和fd_out至少有一个是管道文件描述符。
成功时返回移动的字节数,失败时返回-1并设置errno。还可能返回0,表示没有移动任何数据,这种情况下通常是管道中没有数据可读。
以下暂时跳过直接开始服务器

服务器程序规范

系统日志

守护进程rsyslogd负责收集系统日志,应用程序用syslog函数与rsyslogd通信,rsyslogd将日志写入/var/log目录下的文件中。

用户信息

一个进程有两个用户id,一个是真实用户id,一个是有效用户id。真实用户id用于确定进程的所有者,有效用户id用于确定进程的权限,方便资源访问。UID为启动进程的用户ID,EUID为执行进程的用户ID。set-user-id可以让任何用户运行su程序时,有效用户id变为su程序的所有者的用户id,这样su程序就可以以所有者的权限运行。可以使用chmod +s来设置set-user-id位。

uid_t getuid(void);
uid_t geteuid(void);
int setuid(uid_t uid);
int seteuid(uid_t uid);

进程组

pid_t getpgid(pid_t pid);

返回pid进程所在进程组,每个进程组都有一个首领进程,进程组id等于首领进程id。进程组一直存在直到所有进程都退出。

int setpgid(pid_t pid, pid_t pgid);

将pid进程加入pgid进程组,如果pid=pgid,则将pid设置为首领进程;如果pid=0,则将当前进程加入pgid进程组;如果pgid=0,则将pid设置为pid进程的pgid。

pid_t setsid(void);

创建一个新的会话,使当前进程成为会话首领,成为新建的进程组首领,没有控制终端;如果当前进程是进程组首领,则返回-1并设置errno为EPERM。
linux认为会话id等于会话首领所在进程组pgid

pid_t getsid(pid_t pid);

使用ps -o pid,ppid,sid,pgid,comm | less查看进程信息
输出为

  PID  PPID  PGID   SID COMMAND
 3393    43  3393  3393 bash
29460  3393 29460  3393 ps
29461  3393 29460  3393 less

char* getcwd(char *buf, size_t size);

获取进程当前工作目录的绝对路径名,存入buf指向的空间,size为空间大小,成功返回buf,失败返回NULL并设置errno。

int chdir(const char *path);

切换到path指定的目录,成功返回0,失败返回-1并设置errno。

int chrrot(const char *path);

将根目录设置为path指定的目录,成功返回0,失败返回-1并设置errno。要进入path,还需要调用chdir("/")。

int daemon(int nochdir, int noclose);

创建一个守护进程,如果nochdir为0,则将当前工作目录设置为根目录;如果noclose为0,则将标准输入、输出、错误重定向到/dev/null(空文件,用于将数据彻底丢弃,无数据,没有实际的输入输出)。否则不变

服务器框架

服务器模型

C/S模型:客户端和服务器端分别运行在不同的主机上,客户端向服务器发送请求,服务器响应请求并返回结果。
服务器启动后,创建监听socket,并调用bind绑定到某一端口,然后调用listen等待客户端连接。服务器稳定后客户端调用connect连接服务器,服务器调用accept接受连接,然后调用read和write进行数据交换,最后调用close关闭连接。
由于客户连接时随机到达的异步时间,需要某种IO模型监听,比如select。新的连接服务可以是子进程,也可以是线程。客户端在处理一个请求的同时还会监听其他请求,可以通过select监听多个请求。
服务器是通信中心,访问量过大时响应慢。
csmodel

P2P模型:每台主机既是客户端也是服务器,在消耗服务时也提供服务。但主机之间很难互相发现,实际上有专门的发现服务器。

服务器基本模块

  • IO处理单元。管理客户连接。对于服务器群来说可以是专门的接入服务器,实现负载均衡。
  • 逻辑单元。进程或线程,分析用户数据并将结果发送给IO单元或者客户端。
  • 网络存储单元。数据库、缓存或文件,甚至是独立的服务器。
  • 请求队列。各单元通信方式,通常被实现为池,协调处理竞态条件。

I/O模型
阻塞IO可能因为无法立即完成而被挂起直到等待事件发生,比如connect、accept、send和recv都可以是阻塞的。
非阻塞IO总是立即返回而不管事件是否发生,未发生就返回-1并设置errno,对于accept、send和recv常为EAGAIN(再来一次)或EWOULDBLOCK(期望阻塞),而connect则是EINPROGRESS(在处理中)。通常要与IO通知机制一起使用,比如IO复用(select,poll,epoll)和信号。

  • IO复用函数向内核注册一系列事件,内核把就绪事件通知给程序。IO复用函数本身是阻塞的,但由于具有监听多个IO事件的能力,因此可以提高效率。
  • SIGIO信号。在信号处理函数内调用非阻塞IO函数。
    以上均为同步IO,因为读写操作均为事件发生后 由程序完成的。而异步IO告诉内核读写缓冲的位置以及IO完成后内核通知程序的方式,一步总是立即返回,真正的读写操作由内核完成。因此,同步IO内核向程序通知就绪事件,异步内核通知完成事件
    iomodel
    疑问:多线程不就行了吗?为什么要IO复用?
    copilot解答:多线程的开销比较大,而且线程间的同步也是一个问题,而IO复用可以监听多个IO事件,因此可以提高效率。

事件处理方式

Reacter模式
主线程只负责监听,当有事件发生时,将事件通知工作线程,读写和接受新的连接(?这个接受连接不太清楚,书上这样写的)、处理客户请求均由工作线程完成。
reacter

Proactor模式
所有IO操作交给主线程和内核处理,工作线程负责业务逻辑。
proactor

并发模式

半同步/半异步模式
这里的同步是指程序完全按照代码顺序执行,异步是指程序执行由系统事件驱动,比如中断和信号。
concurrent
这种模式中同步线程用于处理客户请求,异步线程用于处理IO。

半同步/半反应模式

主线程管理监督套接字和连接套接字,工作线程处理IO。
halfreactive
主线程和工作线程共享请求队列,主线程添加任务,工作线程去除任务都需要加锁。而且工作线程在同一时间只能处理一个客户请求。

更高效的模式

主线程只管理监听套接字,连接套接字由工作线程管理。
主线程通过管道向工作线程派发套接字,工作线程检测到管道可读时,就分析是否有一个新的连接到来。主线程和工作线程维持自己的事件循环

领导者/追随者模式
跳过

他给的http代码感觉一般。。。不如csapp的好

IO复用

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

nfds指定被监听的文件描述符总数,通常为监听的最大文件描述符+1,readfds、writefds、exceptfds分别为读、写、异常事件的文件描述符集合,timeout为超时时间,NULL表示永远等待,0表示立即返回,其他值表示等待指定时间。

#define FD_SETSIZE 1024
typedef struct{
	unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(long))];
} fd_set;

可以看出fd_set实际上就是位图,每个位代表一个文件描述符,1表示该文件描述符在集合中,0表示不在。select能同时处理的文件描述符数量有限,为FD_SETSIZE。
使用宏访问位

  • FD_ZERO(fd_set *fdset) 将fdset清零
  • FD_SET(int fd, fd_set *fdset) 将fd加入fdset
  • FD_CLR(int fd, fd_set *fdset) 将fd从fdset中删除
  • int FD_ISSET(int fd, fd_set *fdset) 判断fd是否被设置

这些宏实际上都不进行越界判断,而是直接操作。
The behavior of these macros is undefined if a descriptor value is less than zero or greater than or equal to FD_SETSIZE, which is normally at least equal to the maximum number of descriptors supported by the system. 在使用时实际大于FD_SETSIZE产生越界,结果不确定。这时候需要手动分配内存用于存放fd_set。

timeout是timeval类型的指针,设置select的超时时间,内核修改其值,以便返回实际等待时间。有定义可以看出提供了微妙级别的精度,均设置为0时立即返回,timeout设置为NULL时一直阻塞直到某个文件描述符就绪。

struct timeval{
	long tv_sec; //秒
	long tv_usec; //微秒
}

成功时返回就绪文件描述符的总数,超时返回0,失败返回-1并设置errno。

ready

socket接收到普通数据和带外数据都会使select返回,但前者处于可读状态,后者处于异常状态。

int poll(struct pollfd *fds, nfds_t nfds, int timeout)

和select类似,也是在一定时间内轮询一定数量的文件描述符,测试是否有就绪的。
pollfd结构体

struct pollfd{
	int fd; //文件描述符
	short events; //请求事件
	short revents; //返回事件
}

fd指定文件描述符,events指定请求事件(按位或),revents指定返回事件(由内核修改),events和revents都是位掩码,可以使用宏访问。
nfds指定fds数组中的文件描述符总数,timeout指定超时时间,-1表示永远等待,0表示立即返回,其他值表示等待指定时间。

epoll是linux特有的IO复用函数,比select和poll更加高效,但是使用起来更加复杂。需要先调用epoll_create创建一个epoll句柄,然后调用epoll_ctl向句柄中添加文件描述符,最后调用epoll_wait等待就绪事件。

epoll把用户关心的文件描述符上的事件放在内核事件表中,使用额外的文件描述符来唯一标识内核中的事件表项。

创建内核事件表

int epoll_create(int size);

size实际不起作用,只是给内核一个提示,告诉内核事件表多大。返回值作为epoll句柄,用于后续的epoll操作,即作为epoll函数的第一个参数。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

操作内核事件表
op指定操作,EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL分别表示添加、修改、删除事件。
event指定事件,epoll_event结构体

typedef union epoll_data{
	void *ptr;
	int fd;
	uint32_t u32;
	uint64_t u64;
} epoll_data_t;

struct epoll_event{
	uint32_t events; //事件类型
	epoll_data_t data; //用户数据
}

events指定事件类型,和poll基本相同,宏前面多了E。
epoll_data_t用于存放用户数据,使用最多的是文件描述符fd,指定事件所属的文件描述符;ptr用来指定和fd相关的用户数;u32和u64用于存放32位和64位的用户数据。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

函数将所有就绪事件从内核事件表中复制到events数组中,maxevents指定events数组的大小,timeout指定超时时间。成功返回就绪事件的个数,超时返回0,失败返回-1并设置errno。效率比select和poll高,因为不需要遍历整个事件表,只需要遍历就绪事件。

LT模式和ET模式
LT(level triggered电平触发)模式是默认模式,当epoll_wait检测到事件并通知应用程序后,应用程序可以不立即处理,下次调用epoll_wait时还会通知,直到事件被处理。
ET(edge triggered边缘触发)模式中,应用程序必须立即处理事件,下次调用epoll_wait时不会再通知。
ET降低了同一个时间被重复触发的次数,因此效率更高。

EPOLLONESHOT
epoll的ET模式下,当一个线程正在处理某个文件描述符的事件时,其他线程不能处理该文件描述符的事件,因此需要加锁。但是加锁会降低效率,因此可以使用EPOLLONESHOT选项,当一个线程正在处理某个socket时,其他线程不会再处理该socket,直到被处理完,该线程应该立即重置该socket的EPOLLONESHOT选项,以便下一次可读时其他工作线程可以处理该socket的事件。

三种IO复用函数的比较
select没有将文件描述符和事件绑定,只是文件描述符集合,并且只提供了三种类型的事件;由于内核在线修改,程序下次调用还需要重新设置三个fd_set,因此效率低。
poll将文件描述符和事件绑定,events成员保持不变,因此不需要重置,效率比select高。同时poll没有最大文件描述符数量1024的限制。
以上两个调用都返回整个文件描述符集合,因此索引就绪文件描述符的时间复杂度为O(n)。
epoll在内核中添加了事件表,将文件描述符和事件绑定,只返回就绪事件,因此效率最高,索引就绪文件描述符的时间复杂度为O(1)。

使用规则如下
threeio

一篇不错的文章

信号

信号处理函数

int kill(pid_t pid, int sig);

给其他进程发送信号,目标进程由pid指定,sig指定信号类型,成功返回0,失败返回-1并设置errno。

  • pid>0,发送信号给进程号为pid的进程
  • pid=0,发送信号给与调用进程同一进程组的所有进程,包括调用进程自己
  • pid=-1,发送信号给所有进程,除了进程1(init)
  • pid<-1,发送信号给进程组号为-pid的所有进程

信号处理函数

void (*sighandler)(int);

需要是可重入函数。系统定的处理方式有SIG_IGN(忽略)、SIG_DFL(默认)。
如果程序执行处于阻塞状态的系统调用时接收信号,并且设置了信号处理函数,则默认系统调用被中断,errno被设置为EINTR。
为信号设置处理函数

sighandler_t signal(int signum, sighandler_t handler);
返回值是上一次调用时设置的信号处理函数或者默认处理方式SIG_DFL。出错时返回SIG_ERR并设置errno。

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

signum指定信号类型,act指定新的信号处理方式,oldact输出原来的信号处理方式。

struct sigaction{
	void (*sa_handler)(int); //信号处理函数
	sigset_t sa_mask; //信号屏蔽字,指定哪些信号不能发送给本进程
	int sa_flags; //信号处理标志,设置程序收到信号时的行为
	// void (*sa_sigaction)(int, siginfo_t *, void *); //信号处理函数
}

// 和fd_set类似
struct sigset_t{
	unsigned long sig[1024 / (8 * sizeof(long))];
}

sa_flags如下
sa_flags
查询和修改信号屏蔽字

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

set指定新的信号屏蔽字,oldset输出原来的信号屏蔽字,how指定如何修改,SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK分别表示将set添加到、移除、替换为原来的信号屏蔽字。

int sigemptyset(sigset_t *set); //清空信号屏蔽字
int sigfillset(sigset_t *set); //将所有信号添加到信号屏蔽字
int sigaddset(sigset_t *set, int signum); //将signum指定的信号添加到信号屏蔽字
int sigdelset(sigset_t *set, int signum); //将signum指定的信号从信号屏蔽字中删除
int sigismember(const sigset_t *set, int signum); //判断signum指定的信号是否在信号屏蔽字中

设置信号屏蔽字后,被屏蔽的信号不会被接受,但会记录在被挂起的信号集中,当信号屏蔽字解除时,被挂起的信号会被发送给进程。因此即使进程接收到多个相同被挂起(或未处理)信号,最后解除屏蔽时进程实际最后也只反映一次,即未处理信号不排队。正在处理某个信号时,该信号会被屏蔽,这就导致可能出现信号丢失的情况,因此信号不能用来计数,实际编写时信号处理函数应该尽可能快地执行完毕。

int sigpending(sigset_t *set); //获取被挂起的信号集

一种典型做法时把信号的主要处理逻辑放在主循环中,信号被触发时,只是简单地通知主循环接受信号,主循环再根据信号执行相应的处理逻辑。
信号处理函数通常使用过管道将信号传递给主循环,这样我们就可以使用IO复用函数来监听管道的读端,这样就能使信号时间和其他IO事件一样被处理,同意了事件源。

网络编程相关信号

SIGHUP
当用户从终端注销时,终端驱动程序会向前台进程组中的每个进程发送SIGHUP信号,通常用于通知进程重新读取配置文件。
比如xinid程序,收到SIGUP信号后会调用hard_reconfig函数,该函数会重新读取配置文件/etc/xinetd.d下的所有文件,如果子服务的配置文件被修改以停止服务,xinetd将会给自服务发送SIGTERM信号,如果子服务的配置文件被修改以启动服务,xinetd将会创建新的socket并绑定到服务对应的端口上。

SIGPIPE
向关闭了读端的管道或socket写数据时,内核会向进程发送SIGPIPE信号,默认行为是终止进程,send设置MSG_NOSIGNAL标志可以防止该信号的发送,返回EPIPE错误,这样通过返回值而不是信号来通知进程。
此外还可以用IO复用检测读端是否关闭,对poll来说,管道读端关闭时,写端文件描述符触发POLLHUP事件。socket连接被对方关闭时,文件描述符触发POLLRDHUP事件。

SIGURG
内核使用SIGURG通知应用程序带外数据到达

定时器

定时要求在一段时间后触发某段代码
Linux定时方法

socket选项

SO_RCVTIMEO和SO_SNDTIMEO分别用于设置接收和发送超时时间,超时后系统调用返回错误并设置errno为EAGAIN或EWOULDBLOCK,connect特殊,超时后返回EINPROGRESS。
实际上就算设置了超时时间,无论正常还是出错都不一定超时。

SIGALRM

unsigned int alarm(unsigned int seconds);

定时器包括超时时间和任务回调函数,以及一些可能的参数
升序链表
使用tick函数依次处理每个定时器,如果超时则调用回调函数,然后删除该定时器。
处理非活动连接

高性能定时器

时间轮
使用哈希的思想,将定时器散列到不同的链表上,也就是槽,每个槽之间的时间间隔为一个最小时间单位,即si(slot interval),而槽链表上的定时器时间差为N*si,N为槽的总个数。这样的好处是插入效率高。
时间堆
tick时间不固定,每次将所有定时器中超时时间最小的时间作为间隔,这样tick函数调用时,最小的定时器必然超时,反复选取最小时间,实现了较为精准的定时。

框架Libevent

Reactor模式包括的组件有

  • 句柄 IO处理的对象,即IO时间,信号和定时事件,统称为信号源,党内和检测到就绪事件时,通过句柄通知事件处理器。Linux中IO句柄是文件描述符,信号句柄是信号值。
  • 事件多路处理器 使用IO复用技术等待事件,一般将系统函数封装成统一接口
  • 事件处理器 包含多个回调函数,执行事件业务逻辑
    reactor

进程线程

这部分操作系统学过很多了,因此只记录没学过的点。
父子进程具有相同的数据(写时复制)和文件描述符表,但使系统打开文件表中的引用计数加1,当前工作目录等引用计数也加1

exec系统调用

l表示以变参数的形式传递参数(最后一个参数为NULL),v表示以数组的形式传递参数(数组以NULL结尾)
p表示可以只给文件名,自动在PATH环境变量中查找,不带p要给出完整路径或者在当前目录下给出文件名
e表示以数组的形式传递环境变量,替换继承的环境变量

如果使用SIGCHLD信号处理子进程,函数中要把wait放在循环中,一次性尽可能处理完所有子进程,否则可能会出现僵尸进程(未处理信号只能有一个,多余的被丢弃)。

僵尸进程

这个总是忘记,因此也记一下吧()
如果父进程先结束,子进程的父进程变为init进程(父进程exit中会将自己的所有子进程的父进程设置为init),init进程会调用wait回收子进程,因此不会产生僵尸进程。
如果子进程先结束,父进程不调用wait然后结束,这样并没有危害,因为父进程结束时仍回想上面一样子完成reparent操作,子进程仍会被init进程回收。
但是如果子进程结束后,父进程没有调用wait或waitpid来获取子进程的状态信息,且一直运行而没有终止,这样子进程的进程表项仍然保留,这种进程称为僵尸进程。僵尸进程占用一定内存空间(exit时会释放大部分,比如打开文件和占用的内存),如进程号,如果父进程不断创建子进程,那么子进程结束后会产生大量的僵尸进程,导致进程号耗尽。

System V信号量

注意和posix信号量不一样,不想看了

动态创建进程或线程实现并发缺点:

  • 耗时,创建进程或线程需要时间;
  • 通常动态创建的一个进程或线程只为一个客户服务,进程切换或线程切换的CPU开销比较大;
  • 动态创建的进程是当前进程的完整镜像,必须谨慎地管理进程的资源。

进程池是由服务器预先创建的一组子进程,这些进程运行相同的代码具有相同的属性。由于很早创建,这些进程都相对干净,没有占用太多资源。当客户请求到达时,服务器从池中选择一个子进程为客户服务。

  • 主进程使用随机算法或轮流算法等选择子进程
  • 主进程和子进程使用共享的工作队列,唤醒等待任务的子进程

主进程为了向子进程通知并传递必要数据,可以预先建立管道来实现进程间通信
processpool

半同步/半异步
事件驱动,使用统一信号源完成,详见用进程实现的代码
半同步/半反应
使用一个工作队列完全解除了主线程和工作线程的耦合,主线程在工作队列中添加任务,工作线程竞争获得任务并执行,但是前提是客户请求是无状态的,因为同一连接的不同请求可能被不同的工作线程处理。

代码流程

  • 建立信号管道,并使用addsig设置信号处理函数
  • 使用epoll将通信管道注册到内核事件表中,并将管道文件描述符设置为非阻塞
  • 主循环中使用epoll_wait监听事件,当有事件发生时,遍历events数组,根据文件描述符判断是需要建立连接(listenfd),信号处理(pipefd[0])还是网络数据传送

webserver项目

主要学习的这个网址
看别人写感觉还是挺简单的,主要是封装技巧,http解析,epoll和多线程的使用,还有一些细节,比如定时器,日志等,也有看不懂的地方。
基本上和书中学习的有很多相似的地方,但使用c++ 11更简洁。

附录杂项

回车换行

Windows系统中有如下等价关系:
用enter换行 <---> 程序写\n <----> 真正朝文件中写\r\n(0x0d0x0a) <---->程序真正读取的是\n
linux系统中的等价关系:
用enter换行 <----> 程序写\n <----> 真正朝文件中写\n(0x0a) <----> 程序真正读取的是\n

ps命令

ps 默认显示进程号,中断和cpu时间
可以使用BSD风格的选项(前面不加-),UNIX风格的选项(前面加-),也可以使用GNU风格的选项(前面加--)
ps -A 显示所有进程
ps -e 显示所有进程
ps -f 显示进程关系
ps -u user 显示用户信息
ps -l 长格式显示
ps -p pid 显示指定进程
ps axjf 显示进程树
常用ps -ef 或ps aux查看全部进程信息,配合grep查找特定信息,配合sort排序。
自定义格式:
ps -o pid,ppid,sid,pgid,comm

表头含义
F:进程的flag,4代表root
S:进程的状态stat
UID:执行者的UID
PID:进程号
PPID:父进程的id
C:占用CPU资源的百分比
PRI:指进程的执行优先级,越小越早被执行
NI:代表进程的nice值,表示进程可被执行的优先级的修正数值
ADDR:代表进程的地址,它指出该进程在内存的哪个部分,正在运行的程序是"-"
SZ:占用的内存大小
WCHAN:判断当前进程是否正在运行,正在运行为"-"
TTY:该进程使用的终端
TIME:占用CPU的时间
CMD:所下达的指令名称

控制终端

控制终端:一个会话一般会拥有一个控制终端用于执行IO操作,会话首领打开的第一个终端成为会话的控制终端,该进程也称为控制进程。一个会话只能有一个控制终端
前台进程组:能够向终端设备进行读写操作的进程组
后台进程组:只能向终端设备写

  1. 每个会话有且只有一个前台进程组,但会有0个或者多个后台进程组。
  2. 产生在控制终端上的输入和信号将发送给会话的前台进程组中的所有进程。对于输出来说,则是在前台和后台共享的,即前台和后台的打印输出都会显示在屏幕上。
  3. 终端上的连接断开时 (比如网络断开), 挂起信号将发送到控制进程 。
  4. 一个用户登录后创建一个会话。一个会话中只存在一个前台进程组,但可以存在多个后台进程组。
  5. 第一次登陆后第一个创建的进程是shell,也就是会话的领头进程,该领头进程处于一个前台进程组中并打开一个控制终端可以进行数据的读写。当在shell里运行一行命令后(不带&)创建一个新的进程组,命令行中如果有多个命令会创建多个进程,这些进程都处于该新建进程组中,shell将该新建的进程组设置为前台进程组并将自己暂时设置为后台进程组。
    带&会创建后台进程组,shell不会等待该进程组的命令执行完毕,而是立即返回,继续等待用户输入命令。

cs144

本人学习计网时完成的CS144实验地址
博客
github

posted @ 2024-02-02 22:23  trashwin  阅读(15)  评论(0编辑  收藏  举报