纸上得来终觉浅,绝知此事要躬行。
内容介绍
-
一、(已更)从CS:APP ProxyLab这辆玩具车出发 Github - Naive-Proxy的实现
-
二、(已更)尝试改进玩具车Proxy:线程与I/O复用 Github - Mature-Proxy的实现
本篇内容适合了解一点点C/C++,学过CS:APP的朋友们,亦或者有大二程度的计算机网络基础,想要了解更多实用的网络编程的朋友们。 本文是一个庞大知识体系的引子,为了可读性,我不得不牺牲很大一部分的细节,但是别担心,对于那些感兴趣的朋友,我会在适当的地方插入超链接。
也许你只是认真学习了本科所教的计算机网络这门课,但是觉得从第一章开始就被各种概念填满的这门课无外乎是计算机专业中的一门"文科",记一记,背一背,在考试顺利通过之后,并没能认识到这门课到底有何实用意义。亦或者你是刚学完CS:APP的网络编程一章并且实现了ProxyLab之后,总觉意犹未尽,因为短短几百行的ProxyLab实在是有一点太玩具,其功能也无法超乎教学需求之外。对于前者,建议先尝试花一些时间好好看看第一节和第二节的内容。
一、从CS:APP ProxyLab这辆玩具车出发
1.1 互联网
互联网是一个极其庞大且复杂的通信网络系统,也是人类最伟大的工程学奇迹之一。到底有多少人使用互联网?到底有多少台设备相互连接?我相信,无论多少次列举这个至今仍在迅速增长的数字,都会让人深深地感受到不可思议:
如果说实现如此规模庞大的复杂系统之间端到端的通信,只需要掌握几十个简单的函数,你是不是会相信这个世界也存在魔法?
计算机世界的魔法之一,就是封装
问:考虑主机A如何将数据传送到主机B:
- 应用层:把数据打包(HTTP/FTP等)
- 传输层:加上端口号(TCP/UDP)
- 网络层:加上IP地址,选择路径
- 链路层:加上MAC地址,变成帧
- 物理层:变成电信号/光信号发送
A → 交换机 → 路由器 → 更多路由器 → 交换机 → B
在这个过程中数据被层层封装,经过路由交换,最终解包送达。
如果内核会说话,那么以上将会是他给出的答案,这是从计算机网络协议栈的视角来看。从一个程序员的视角,神奇的内核为我们提供了网络编程所需要的几十条极其简单的函数调用,我们将通过回顾CSAPP的ProxyLab,来帮助你理解所需要知道最基本的实用网络编程知识。
1.2 网络编程基础
当神奇内核和封装魔法为我们屏蔽掉了实际网络中的复杂度,主机A与主机B之间的通信就变成了一种端到端问答对话,高度概括来说,我们引入客户端-服务器模型
这种端到端的对话需要一个基本的信息,就是(客户端IP地址:端口, 服务器IP地址:端口),由于结构上将客户端的信息和服务器的信息套接在一起,所以被称为套接字(socket)。
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]; /* struct sockaddr大小*/
};
/* 通用套接字地址结构 (用于connect函数, bind函数, 和accept函数) */
struct sockaddr {
uint16_t sa_family; /* 协议族 */
char sa_data[14]; /* 地址数据 */
};
图1.1 socket套接字地址结构
有些奇怪的地方,不妨可以问问Deepseek:
-
为什么明明可以用
uint32_t直接表示IP地址,但是却使用struct in_addr这种东西? -
计算机系统中明明通行小端法表示字节顺序,但是套接字当中却统一使用大端法?
-
为什么定义了
struct sockaddr_in还需要struct sockaddr?
我们将ProxyLab作为基本套接字网络应用的引子,边做边学。 什么是Proxy?----Proxy是代理服务器的名称,试比较我们之前提到的客户端-服务器模型:
真实客户端 → Proxy → 真实服务器
可以看到,Proxy的定位就是在客户端与服务器之间充当中间人,之所以要强调真实客户端以及真实服务器,是因为Proxy对客户端而言是虚拟服务器,而对于服务器而言,是虚拟客户端。,所以,ProxyLab其实是两重的服务器客户端模型。所以,其实我们只需要了解那个最基本的客户端-服务器模型具体如何工作,就能够理解ProxyLab了,为此,我们需要了解一些套接字接口函数,还有一个概念:*描述符:网络编程中的描述符就是一个整数"门票号",它是操作系统用来唯一标识和管理网络连接(如Socket)的依据,应用程序通过它来操作对应的网络连接。
套接字接口函数
socket函数:客户端或者服务器都可调用,返回一个非负的socket套接字描述符,创建起通信双方的一个端点,默认认为是客户端调用的,并理解为主动描述符
客户端用以连接服务器的函数:
connect函数:调用时阻塞,直到建立起连接或者返回错误,如果成功,则将通过socket函数创建的描述符变为已连接的客户端描述符clientfd,建议使用getaddrinfo函数为它提供参数
服务器用于同客户端建立连接的函数:
-
bind函数:联系起socket_addr(服务器套接字地址)与socket套接字描述符,成功则返回0,否则-1 -
listen函数:若服务器调用此函数,则将通过socket函数创建的描述符从默认的主动描述符转化为监听描述符listenfd,同样建议使用getaddrinfo来提供参数 -
accept函数:等待来自客户端的连接请求到达监听描述符listenfd,到达后,在socket_addr当中填写客户端的套接字地址,并返回已连接描述符connfd
有了这些基本的工具,我们就可以建立起一个Naive版本的Proxy。具体代码就不在这贴了,附上连接,有比较详细的注释。 Github - Naive-Proxy的实现
二、尝试改进Naive-proxy:线程与I/O复用
为什么将我们上面的proxy取名为naive?考虑如下两个问题:
-
问题一、多个客户端并发请求服务器正在监听的某一端口,naive-proxy会如何?
-
问题二、这些并发请求中,若处理某一个客户端的请求需要分配高性能时,naive-proxy会如何?
我希望用一个现实中的例子来模拟:假设客户端连接是等待打饭的学生,我们的proxy或webserver是饭堂工作人员:
首先,让我们来考虑问题一,naive-proxy的天真之处之一就在于它假设客户端的请求将会十分有序,就像在食堂捧着餐盘排队的学生们。但是,事实不是如此,高并发才是proxy或者webserver需要面对的常态,换句话说,当学生们不再彬彬有礼,客客气气,而是和三天没吃饭的饿死鬼一样蜂拥而上。我们可怜的饭堂阿姨也只能一边给手伸得最长、盘子递得最前的饿死鬼拍一勺饭,一边大声却无济于事地喝斥排队。
然后,我们考虑问题二:naive-proxy的天真之处之二在于它假设客户端的请求一定是自己hold得住的、可以在合理时间内服务完毕的,假设这回,它遇到了一个请求资源十分大的连接请求:比方食堂阿姨遇到了一个奇怪的同学,他身材黝黑矮小,却拖着一个几乎和他体型一样大的麻袋,不问不知道,原来里面装着全班人的餐盘!这回不只是阿姨傻眼了,蜂拥而上的同学们也在旁边傻眼了----只能眼巴巴看着阿姨先给这位同学的全班打饭。
当然了,对于食堂打饭的问题,如果我们有足够多的食堂窗口,并且聘请足够多的食堂阿姨,然后请思政老师好好把关学生素质,让他们自觉遵守校规,不是什么难事。但是计算机世界不是这样运作的!
我们为proxy提供的解决方案如下:
-
针对问题一:使用I/O复用技术,关键在于
select函数来监控多个描述符的状态,这样我们就直到应该处理哪些个连接请求 -
针对问题二:使用线程池技术,关键在于不同请求类型都可创建线程占用一个CPU执行
select如何使用
select是一个用于监视多个socket事件的系统调用,其所处理的数据结构是描述符集合fd_set, 并且修改fd_set中描述符的状态变化(它所监控的状态有三种----可读、可写、异常)。
fd_set是什么?
本质上,fd_set 是一个固定大小的位数组(bit array)。每一个位(bit)代表一个文件描述符。
-
如果某个位被设置为 1,表示对应的文件描述符正在被监视。
-
如果为 0,则表示不关心该文件描述符。
例如,在一个 1024 位的 fd_set 中:
-
位 0 (第1位) 代表文件描述符 0 (标准输入)
-
位 5 (第6位) 代表文件描述符 5,以此类推...
在 Linux 中,fd_set 的大小通常是固定的,由常量 FD_SETSIZE 定义。 这个值通常是 1024。这意味着 select 能同时监视的文件描述符数量上限是 1024。
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout); //timeout是可选项
图2.1 select函数原型
通过一些宏,我们可以操作描述符集合:
-
FD_ZERO(&set): 清空集合
-
FD_SET(fd, &set): 添加文件描述符到集合
-
FD_CLR(fd, &set): 从集合中移除文件描述符
-
FD_ISSET(fd, &set): 检查文件描述符是否在集合中
线程的简单介绍
线程是一种运行在进程上下文当中的逻辑流,也就是说,不同的线程只不过是同一进程之中的切换,共享这个进程当中的整个虚拟地址空间的所有内容。
int main() {
pthread_t tid; //线程号tid
Pthread_create(&tid, NULL, thread, NULL); //线程创建
Pthread_join(tid, NULL); //调用线程
exit(0);
}
//线程所执行的内容
void *thread(void *vargp) {
printf("hello world\n");
return NULL;
}
图2.2 线程版本的hello world
mature-proxy 当中所涉及的线程代码十分简单,不过值得多嘴一提:
-
Pthread_join调用的时候,会发生阻塞,直到线程tid的内容执行完毕,然后返回一个指向通用(void)指针,在hello程序当中,这个指针指向的位置是NULL。然后,线程所使用的资源被回收。 -
线程是可结合的或者分离的,一个可结合的线程能够被其他线程所回收和杀死,然而,一个分离的线程所占用的资源只能在其终止时被系统自然释放。我们可以通过
int pthread_detach(pthread_t tid)函数来创建一个分离的线程,如果我们需要分离这个线程他自己,可以通过pthread_self函数来获取自己的tid。
在mature-proxy当中,我们用线程改进naive-proxy的思路是创建一些线程池,也就是说,为了避免需要使用线程的时候我们还需要临时创建这种情况,我们提前创建了合理数目的线程,并且在需要使用的时候直接就可以使用。这种方法被称为线程池。
有兴趣了解线程池技术的朋友,可以参考下我写的另一篇有关线程池的文章。