在 “了不起的 Deno 入门教程”这篇文章中,我们介绍了如何使用 Deno 搭建一个简单的 TCP echo server,本文将使用该示例来探究 TCP echo server 是怎么运行的?前方高能,请小伙伴们深吸一口气做好准备。 了不起的 Deno 入门教程创建了一个 “重学TypeScript” 的微信群,想加群的小伙伴,加我微信 "semlinker",备注重学TS。
本来计划重写 18 年写的 “深入学习 Node.js” 系列,然而 Deno 它来了,那就从 Deno 1.0.0 开始吧。
“深入学习 Node.js” 仓库地址:https://github.com/semlinker/node-deep,有兴趣的小伙伴可以了解一下。
一、搭建 TCP echo server
好了,废话不多说,我们进入正题。首先我们先来回顾一下之前所写的 TCP echo server,具体代码如下:
echo_server.ts
const listener = Deno.listen({ port: 8080 });
console.log("listening on 0.0.0.0:8080");
for await (const conn of listener) {
Deno.copy(conn, conn);
}
for await...of 语句会在异步或者同步可迭代对象上创建一个迭代循环,包括 String,Array,Array-like 对象(比如 arguments 或者 NodeList),TypedArray,Map, Set 和自定义的异步或者同步可迭代对象。
for await...of 的语法如下:
for await (variable of iterable) {
statement
}
接着我们使用以下命令来启动该 TCP echo server:
$ deno run --allow-net ./echo_server.ts
这里需要注意的是,在运行 ./echo_server.ts
时,我们需要设置 --allow-net
标志,以允许网络访问。不然会出现以下错误信息:
error: Uncaught PermissionDenied: network access to "0.0.0.0:8080",
run again with the --allow-net flag
为什么会这样呢?这是因为 Deno 是一个 JavaScript/TypeScript 的运行时,默认使用安全环境执行代码。当服务器成功运行之后,我们使用 nc
命令来测试一下服务器的功能:
$ nc localhost 8080
hell semlinker
hell semlinker
nc 是 netcat 的简写,有着网络界的瑞士军刀美誉。因为它短小精悍、功能实用,被设计为一个简单、可靠的网络工具。
nc 的作用:
1.实现任意 TCP/UDP 端口的侦听,nc 可以作为 server 以 TCP 或 UDP 方式侦听指定端口;
2.端口的扫描,nc 可以作为 Client 端发起 TCP 或 UDP 连接;
3.机器之间传输文件或机器之间网络测速。
下面我们来分析一下从启动 TCP echo server 服务器开始,到使用 nc
命令连接该服务器这期间发生了什么?
二、TCP echo server 运行流程分析
2.1 启动 TCP echo server
在命令行运行 deno run --allow-net ./echo_server.ts
命令后,当前命令行会输出以下信息:
listening on 0.0.0.0:8080
表示我们的 TCP echo server 已经开始监听本机的 8080 端口,这里我们可以使用 netstat
命令,来打印 Linux 中网络系统的状态信息:
[root@izuf6ghot555xyn666xm888 23178]# netstat -natp
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 23178/deno
通过观察以上输出的网络信息,我们发现当前 TCP echo server 处于 LISTEN 监听状态,且当前进程的 PID 是 23178。
在 Linux 中,一切都是文件。在 Linux 的根目录下存在一个 /proc 目录,/proc 文件系统是一种虚拟文件系统,以文件系统目录和文件形式,提供一个指向内核数据结构的接口,通过它能够查看和改变各种系统属性。
下面我们进入 23178 进程目录并使用 ls -l | grep '^d'
命令查看当前目录下的子目录信息:
[root@izuf6ghot555xyn666xm888]# cd /proc/23178
[root@izuf6ghot555xyn666xm888 23178]# ls -l | grep '^d'
dr-xr-xr-x 2 root root 0 May 17 13:17 attr
dr-x------ 2 root root 0 May 17 13:16 fd
dr-x------ 2 root root 0 May 17 13:29 fdinfo
dr-x------ 2 root root 0 May 17 13:29 map_files
dr-xr-xr-x 5 root root 0 May 17 13:29 net
dr-x--x--x 2 root root 0 May 17 13:16 ns
dr-xr-xr-x 4 root root 0 May 17 13:16 task
下面我们主要分析 /proc/pid/task
和 /proc/pid/fd
这两个目录:
2.1.1. /proc/pid/task 目录
该目录包含的是进程中的每一个线程。每一个目录的名字是以线程 ID 命名的(tid)。在每一个 tid 下面的目录结构与 /proc/pid
下面的目录结构相同。对于所有线程共享的属性,task/tid
子目录中的每个文件内容与 /proc/pid
目录中的相应文件内容相同。 比如所有线程中的 task/tid/cwd
文件和父目录中的 /proc/pid/cwd
文件内容相同,因为所有的线程共享一个工作目录。对于每个线程的不同属性,task/tid
下相应文件的值也不相同。
对于我们的 Deno 进程( 23178 ),我们使用 ls -al
命令查看 /proc/23178/task
目录的信息:
[root@izuf6ghot555xyn666xm888 task]# ls -al
total 0
dr-xr-xr-x 4 root root 0 May 17 13:16 .
dr-xr-xr-x 9 root root 0 May 17 13:15 ..
dr-xr-xr-x 6 root root 0 May 17 13:16 23178
dr-xr-xr-x 6 root root 0 May 17 13:16 23179
接下来我们进入 /proc/23178/task
目录,来开始分析 /proc/pid/fd
目录。
2.1.2 /proc/pid/fd 目录
该目录包含了当前进程打开的每一个文件。每一个条目都是一个文件描述符,是一个符号链接,指向的是实际打开的地址。其中 0 表示标准输入,1 表示标准输出,2 表示标准错误。在多线程程序中,如果主程序退出了,那么这个文件夹将不能被访问。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于 UNIX、Linux 这样的操作系统。
每个 Unix 进程(除了可能的守护进程)应均有三个标准的 POSIX 文件描述符,对应于三个标准流:
整数值 名称 unistd.h符号常量 stdio.h文件流 0 Standard input STDIN_FILENO stdin 1 Standard output STDOUT_FILENO stdout 2 Standard error STDERR_FILENO stderr
对于我们的 Deno 进程( 23178 ),我们使用 ls -al
命令查看 /proc/23178/fd
目录的信息:
[root@izuf6ghot555xyn666xm888 fd]# ls -al
total 0
dr-x------ 2 root root 0 May 17 13:16 .
dr-xr-xr-x 9 root root 0 May 17 13:15 ..
lrwx------ 1 root root 64 May 17 13:16 0 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 1 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 2 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 3 -> anon_inode:[eventpoll]
lr-x------ 1 root root 64 May 17 13:16 4 -> pipe:[30180039]
l-wx------ 1 root root 64 May 17 13:16 5 -> pipe:[30180039]
lrwx------ 1 root root 64 May 17 13:16 6 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 7 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 8 -> socket:[30180040]
观察以上输出结果,我们发现除了 0-2 文件描述符之外,我们的 Deno 进程( 23178 )还包含了其他的文件描述符。
这里我们重点关注文件描述符 8,根据输出结果可知,它表示一个 Socket。那么这个 Socket 是什么时候创建的呢?这个问题我们先记着,后面我们会一起探究内部的创建过程。
接下来我们来分析下一个流程,即使用 nc
命令来连接我们的 TCP echo server。
2.2 连接 TCP echo server
接下来我们使用前面介绍的 nc
命令,来连接我们的 TCP echo server:
[root@izuf6ghot555xyn666xm888 ~]# nc localhost 8080
接着在键盘中输入 hello semlinker
,此时在当前命令行会自动回显 hello semlinker
。这时,我们先来使用 netstat
命令来查看当前的网络状态,具体命令如下:
[root@izuf6ghot555xyn666xm888 fd]# netstat -natp | grep 8080
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 23178/deno
tcp 0 0 127.0.0.1:55700 127.0.0.1:8080 ESTABLISHED 23274/nc
tcp 0 0 127.0.0.1:8080 127.0.0.1:55700 ESTABLISHED 23178/deno
相信眼尖的小伙伴,已经注意到 23274/nc
这一行,通过这一行,我们可以发现 nc 使用本机的 55700 端口与我们的 TCP echo server 建立了 TCP 连接,因为当前的连接状态为 ESTABLISHED。这时,让我们再次使用 ls -al
命令来查看 /proc/23178/fd
目录的信息,该命令的执行结果如下:
[root@izuf6ghot555xyn666xm888 fd]# ls -al
total 0
dr-x------ 2 root root 0 May 17 13:16 .
dr-xr-xr-x 9 root root 0 May 17 13:15 ..
lrwx------ 1 root root 64 May 17 13:16 0 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 1 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 2 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 3 -> anon_inode:[eventpoll]
lr-x------ 1 root root 64 May 17 13:16 4 -> pipe:[30180039]
l-wx------ 1 root root 64 May 17 13:16 5 -> pipe:[30180039]
lrwx------ 1 root root 64 May 17 13:16 6 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 7 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 8 -> socket:[30180040]
lrwx------ 1 root root 64 May 17 13:46 9 -> socket:[30181765]
对比前面的输出结果,当使用 nc
命令与 TCP echo server 建立连接后, /proc/23178/fd
目录下增加了一个新的文件描述符,即 9 -> socket:[30181765]
,它也是用于表示一个 Socket。
好了,现在我们已经看到了现象,那具体的内部流程是怎么样的呢?为了分析内部的执行流程,这时我们需要使用 Linux 提供的 strace
命令,该命令常用来跟踪进程执行时的系统调用和所接收的信号。
三、使用 strace 跟踪进程中的系统调用
为了能够更好地理解后续的内容,我们需要先介绍一些前置知识,比如 Socket、Socket API、用户态和内核态等相关知识。
3.1 文件描述符
Linux 系统中,把一切都看做是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。
当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行 I/O 操作的系统调用都会通过文件描述符。
每一个文件描述符会与一个打开文件相对应,同时,不同的文件描述符也会指向同一个文件。相同的文件可以被不同的进程打开也可以在同一个进程中被多次打开。
系统为每一个进程维护了一个文件描述符表,该表的值都是从 0 开始的,所以在不同的进程中你会看到相同的文件描述符,这种情况下相同文件描述符有可能指向同一个文件,也有可能指向不同的文件。
要理解文件描述符,我们需要了解由内核维护的 3 个数据结构。
- 进程级的文件描述符表;
- 系统级的打开文件描述符表;
- 文件系统的 i-node 表。
下图展示了文件描述符、打开的文件句柄以及 i-node 之间的关系:
(图片来源于网络)
图中两个进程拥有诸多打开的文件描述符。
3.2 Socket
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 socket(套接字),因此建立网络通信连接至少要一对端口号。
socket 本质是对 TCP/IP 协议栈的封装,它提供了一个针对 TCP 或者 UDP 编程的接口,并不是另一种协议。通过 socket,你可以使用 TCP/IP 协议。
Socket 的英文原义是“孔”或“插座”。作为 BSD UNIX 的进程通信机制,取后一种意思。通常也称作"套接字",用于描述IP地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。
在Internet 上的主机一般运行了多个服务软件,同时提供几种服务。每种服务都打开一个Socket,并绑定到一个端口上,不同的端口对应于不同的服务。
Socket 正如其英文原义那样,像一个多孔插座。一台主机犹如布满各种插座的房间,每个插座有一个编号,有的插座提供 220 伏交流电, 有的提供 110 伏交流电,有的则提供有线电视节目。客户软件将插头插到不同编号的插座,就可以得到不同的服务。—— 百度百科
关于 Socket,可以总结以下几点:
- 它可以实现底层通信,几乎所有的应用层都是通过 socket 进行通信的。
- 对 TCP/IP 协议进行封装,便于应用层协议调用,属于二者之间的中间抽象层。
- TCP/IP 协议族中,传输层存在两种通用协议: TCP、UDP,两种协议不同,因为不同参数的 socket 实现过程也不一样。
下图说明了面向连接的协议的套接字 API 的客户端/服务器关系。
3.3 Socket API
(1)socket() 函数:用于创建套接字并配置套接字的各种属性,返回描述符。
int socket(int af, int type, int protocol);
- af 为地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。AF 是 “Address Family” 的简写,INET 是 “Inetnet” 的简写。AF_INET 表示 IPv4 地址,AF_INET6 表示 IPv6 地址。
- type 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字) 和 SOCK_DGRAM(数据报套接字)。
- protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
使用方式:
int tcp_socket = socket(AF_INET, SOCK_STREAM, 0); //创建TCP套接字
int udp_socket = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字
(2)bind() 函数:用于将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。
使用方式:
//创建套接字
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
//创建sockaddr_in结构体变量
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr)); //每个字节都用0填充
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
serv_addr.sin_port = htons(8080); //端口
//将套接字和IP、端口绑定
bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
以上代码,将创建的套接字与 IP 地址 127.0.0.1、端口 8080 进行绑定。
(3)listen() 函数:用于让套接字进入被动监听状态。所谓被动监听,是指当没有客户端请求时,套接字处于 “睡眠” 状态,只有当接收到客户端请求时,套接字才会被 “唤醒” 来响应请求。
int listen(int sock, int backlog);
sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。 这个缓冲区,就称为请求队列(Request Queue)。
当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。需要注意的是,listen() 函数只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。
(4)accept() 函数:当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
它的参数与 listen() 函数是一样的:sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。
accept() 函数会返回一个新的套接字来和客户端通信,addr 保存了客户端的 IP 地址和端口号,而 sock 是服务器端的套接字,大家注意区分。
需要注意的是,listen() 函数只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行,直到有新的请求到来。 介绍完这几个核心的 Socket API,我们来举一个 Server Socket 的示例,从而让大家更好的理解这些函数具体是如何使用。
simple_tcp_demo.c
#include
#include
#include
#include
#include
#include
#define PORT 8080
int main(int argc, char const *argv[]) {
int server_fd, new_socket, valread;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[1024] = {0};
char *hello = "Hello from server";
/* ① 创建监听套接字,使用IPV4地址 */
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("socket failed");
exit(EXIT_FAILURE);
}
/* ② 设置socket相关配置 */
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR,
&opt, sizeof(opt)))
{
perror("setsockopt");
exit(EXIT_FAILURE);
}
/* AF_INET:因特网使用的 IPv4 地址,AF_INET6:因特网使用功能的 IPv6 地址 */
address.sin_family = AF_INET;
/* INADDR_ANY就是指定地址为0.0.0.0的地址,这个地址事实上表示不确定地址,
或“所有地址”、“任意地址”。*/
address.sin_addr.s_addr = INADDR_ANY;
/* 网络端总是用Big endian,而本机端却要视处理器体系而定,比如x86就跟网络端的看法不同,
使用的是Little endian。
htons:Host To Network Short,它将本机端的字节序(endian)转换成了
网络端的字节序 */
address.sin_port = htons( PORT );
/* ③ 绑定到本机地址,端口为8080 */
if (bind(server_fd, (struct sockaddr *)&address,
sizeof(address))<0)
{
perror("bind failed");
exit(EXIT_FAILURE);
}
/* ④ 为了更好的理解 backlog 参数,我们必须认识到内核为任何一个给定的监听套接口维护两个队列:
- 未完成连接队列(incomplete connection queue),每个这样的 SYN 分节对应其中一项:
已由某个客户发出并到达服务器,而服务器正在等待完成相应的 TCP 三次握手过程。这些套接口
处于 SYN_RCVD 状态。
- 已完成连接队列(completed connection queue),每个已完成 TCP 三次握手过程的客户
对应其中一项。这些套接口处于 ESTABLISHED 状态。*/
if (listen(server_fd, 3) 0)
{
perror("listen");
exit(EXIT_FAILURE);
}
/* ⑤ accept()函数功能是,从处于 established 状态的连接队列头部取出一个已经完成的连接,
如果这个队列没有已经完成的连接,accept()函数就会阻塞,直到取出队列中已完成的用户连接为止。*/
/* 在实际开发过程中,此处会使用 while(true) 或 for (;;) 循环处理用户请求*/
if ((new_socket = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen))<0)
{
perror("accept");
exit(EXIT_FAILURE);
}
/* 读取客户端发送过来的数据 */
valread = read( new_socket , buffer, 1024);
printf("%s\n",buffer );
/* 返回数据给客户端 */
send(new_socket , hello , strlen(hello) , 0 );
printf("Hello message sent\n");
return 0;
}
对于上述 simple_tcp_demo.c
代码,可以通过 gcc
进行编译并运行:
$ gcc simple_tcp_demo.c -o simple_tcp_demo && ./simple_tcp_demo
然后我们继续使用 nc
命令来连接该服务器:
$ nc localhost 8080
hello deno
Hello from server%
如果一切正常的话,在命令行终端可以看到以下输出结果:
$ tcp-server gcc simple_tcp_demo.c -o simple_tcp_demo && ./simple_tcp_demo
hello deno
Hello message sent
3.4 用户态和内核态
Linux 操作系统的体系架构分为用户态和内核态(或者用户空间和内核空间)。内核从本质上看是一种软件 —— 控制计算机的硬件资源,并提供上层应用程序运行的环境。 用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括 CPU 资源、存储资源、I/O 资源等。
为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
系统调用时操作系统的最小功能单位。根据不同的应用场景,不同的 Linux 发行版本提供的系统调用数量也不尽相同,大致在 240-350 之间。
这些系统调用组成了用户态跟内核态交互的基本接口。在实际的操作系统中,为了屏蔽这些复杂的底层实现细节,减轻开发者的负担,操作系统为我们提供了库函数。它实现对系统调用的封装,将简单的业务逻辑接口呈现给用户,方便开发者调用。
这里我们以 write() 函数为例来演示一下系统调用的过程:
(图片来源:https://www.linuxbnb.net/home/adding-a-system-call-to-linux-arm-architecture/)
除了系统调用外,我们来简单介绍一下 Shell,相信有的读者已经有写过 Shell 脚本。Shell 是一个特殊的应用程序,俗称命令行,本质上是一个命令解释器,它下通系统调用,上通各种应用,通常充当着一种 “胶水” 的角色,来连接各个小功能程序,让不同程序能够以一个清晰的接口协同工作,从而增强各个程序的功能。
为了方便用户和系统交互,一般情况下,一个 Shell 对应一个终端,终端是一个硬件设备,呈现给用户的是一个图形化窗口。当然前面我们也提到过 Shell 是可编程的,它拥有标准的 Shell 语法,符合其语法的文本,我们一般称它为 Shell 脚本。
那么现在问题来了,如何从用户态切换到内核态呢?要实现状态切换,可以通过以下三种方式:
- 系统调用:其实系统调用本身就是中断,但是软中断,跟硬中断不同。
- 异常:如果当前进程运行在用户态,如果这个时候发生了异常事件,就会触发切换。
- 外设中断:当外设完成用户的请求时,会向 CPU 发送中断信号。
3.5 strace 命令
strace 命令常用来跟踪进程执行时的系统调用和所接收的信号。在 Linux 世界,进程不能直接访问硬件设备,当进程需要访问硬件设备(比如读取磁盘文件,接收网络数据等等)时,必须由用户态模式切换至内核态模式,通过系统调用访问硬件设备。strace 可以跟踪到一个进程产生的系统调用,包括参数、返回值和执行消耗的时间。
接下来我们将使用 strace
命令,来跟踪 Deno TCP echo server 进程的系统调用流程。首先在命令行中输入以下命令:
[root@izuf6ghot555xyn666xm888 deno]# strace -ff -o ./echo_server deno run -A ./echo_server.ts
-ff:如果提供 -o filename,则所有进程的跟踪结果输出到相应的 filename.pid 中,pid 是各进程的进程号。
-o filename:将 strace 的输出写入文件 filename。
当该命令成功运行之后,在 /home/deno
当前目录下会生成以下两个文件:
-rw-r--r-- 1 root root 14173 May 17 13:16 echo_server.23178
-rw-r--r-- 1 root root 137 May 17 13:15 echo_server.23179
为了更直观的了解 23178 和 23179 这两个进程,这里我们再通过 pstree -ap | grep deno
命令将 deno 相关的进程以树状图的形式展示出来:
[root@izuf6ghot555xyn666xm888 deno]# pstree -ap | grep deno
| | `-strace,23176 -ff -o ./echo_server deno run -A ./echo_server.ts
| | `-deno,23178 run -A ./echo_server.ts
| | `-{deno},23179
| |-grep,23285 --color=auto deno
通过观察上述的进程树,我们可以知道我们的 TCP echo server 进程对应的进程 ID 是 23178,我们可以通过查看当前的网络状态来验证我们的猜测:
[root@izuf6ghot555xyn666xm888 deno]# netstat -natp | grep deno
tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 23178/deno
下面我们来打开 /home/deno/echo_server.23178
这个文件,这个文件内容较多,下面我们截取重要的部分:
从图中可知,在 TCP echo server 启动的时候,会调用 socket() 函数,创建监听套接字,之后会将该套接字与本机 0.0.0.0
地址和 8080
端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。接着会继续调用 listen() 函数,如 listen(8, 128)
,让套接字进入被动监听状态。
这时我们进入 /proc/23178/fd
目录,使用 ls -al
查看当前目录的状态,这里我们看到了预想的文件描述 —— 8 -> socket:[30180040]
。
[root@izuf6ghot555xyn666xm888 fd]# ls -al
total 0
dr-x------ 2 root root 0 May 17 13:16 .
dr-xr-xr-x 9 root root 0 May 17 13:15 ..
lrwx------ 1 root root 64 May 17 13:16 0 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 1 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 2 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 3 -> anon_inode:[eventpoll]
lr-x------ 1 root root 64 May 17 13:16 4 -> pipe:[30180039]
l-wx------ 1 root root 64 May 17 13:16 5 -> pipe:[30180039]
lrwx------ 1 root root 64 May 17 13:16 6 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 7 -> /dev/pts/0
lrwx------ 1 root root 64 May 17 13:16 8 -> socket:[30180040]
接下来我们使用 nc
命令,来连接我们的 TCP echo server:
[root@izuf6ghot555xyn666xm888 deno]# nc localhost 8080
前面我们已经知道,当成功创建连接后,/proc/23178/fd
目录下会增加一个新的文件描述符:
lrwx------ 1 root root 64 May 17 13:46 9 -> socket:[30181765]
前面我们已经介绍过了,当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。此外,accept() 函数会返回一个新的套接字来与客户端通信。下面我继续打开 /home/deno/echo_server.23178
这个文件,这里我们找了与 accept 相关的内容:
由图可知文件描述符 9 所对应的 socket 套接字,是在调用 nc
命令之后产生了,当客户端与服务端建立连接后会返回一个新的套接字来与客户端通信。相信有的读者也有注意到,图中除了 accept4
之外,还出现了与 IO 多路复用相关的 epoll_ctl
和 epoll_wait
函数。
epoll 是 Linux 内核的可扩展 I/O 事件通知机制。于 Linux 2.5.44 首度登场,它设计目的旨在取代既有 POSIX select 与 poll 系统函数,让需要大量操作文件描述符的程序得以发挥更优异的性能。epoll 实现的功能与 poll 类似,都是监听多个文件描述符上的事件。
epoll 与 FreeBSD 的 kqueue 类似,底层都是由可配置的操作系统内核对象建构而成,并以文件描述符(file descriptor)的形式呈现于用户空间。epoll 通过使用红黑树(RB-tree)搜索被监视的文件描述符(file descriptor)。
关于 IO 多路复用与 epoll 相关的内容,我们这里就不继续展开了,后续有时间的话,会专门写一下 IO 多路复用的文章,介绍一下 select、poll 和 epoll 这些多路复用器的区别。这篇内容相对会比较难理解,请小伙伴们多多包涵,后续会来篇轻松一点的,分析一下 Deno 标准库的相关实现。
四、参考资源
- socket()函数用法详解
- Linux下/proc目录简介
- strace 跟踪进程中的系统调用
- 怎样去理解Linux用户态和内核态?
- Linux中的文件描述符与打开文件之间的关系
在 TS 中如何减少重复代码
一文读懂 TS 中 Object, object, {} 类型之间的区别一文读懂 TS 中 Object, object, {} 类型之间的区别
遇到这些 TS 问题你会头晕么?遇到这些 TS 问题你会头晕么?
聚焦全栈,专注分享 Angular、TypeScript、Node.js 、Spring 技术栈等全栈干货。
回复 0 进入重学TypeScript学习群
回复 1 获取全栈修仙之路博客地址