【Deno】604- Deno TCP Echo Server 是怎麼運行的?

創建了一個 “重學TypeScript” 的微信羣,想加羣的小夥伴,加我微信 "semlinker",備註重學TS。

了不起的 Deno 入門教程” 這篇文章中,我們介紹瞭如何使用 Deno 搭建一個簡單的 TCP echo server,本文將使用該示例來探究 TCP echo server 是怎麼運行的?前方高能,請小夥伴們深吸一口氣做好準備。

 

了不起的 Deno 入門教程

本來計劃重寫 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文件流
0Standard inputSTDIN_FILENOstdin
1Standard outputSTDOUT_FILENOstdout
2Standard errorSTDERR_FILENOstderr

對於我們的 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 <unistd.h> 
#include <stdio.h> 
#include <sys/socket.h> 
#include <stdlib.h> 
#include <netinet/in.h> 
#include <string.h> 
#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

爲了更直觀的瞭解 2317823179 這兩個進程,這裏我們再通過 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 這個文件,這個文件內容較多,下面我們截取重要的部分:

echo-server-23178-listen

從圖中可知,在 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 相關的內容:

echo-server-23178-accept

由圖可知文件描述符 9 所對應的 socket 套接字,是在調用 nc 命令之後產生了,當客戶端與服務端建立連接後會返回一個新的套接字來與客戶端通信。相信有的讀者也有注意到,圖中除了 accept4 之外,還出現了與 IO 多路複用相關的 epoll_ctlepoll_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 中如何減少重複代碼

 

一文讀懂 TS 中 Object, object, {} 類型之間的區別

一文讀懂 TS 中 Object, object, {} 類型之間的區別

 

遇到這些 TS 問題你會頭暈麼?

遇到這些 TS 問題你會頭暈麼?

聚焦全棧,專注分享 Angular、TypeScript、Node.js 、Spring 技術棧等全棧乾貨。

回覆 0 進入重學TypeScript學習羣

回覆 1 獲取全棧修仙之路博客地址

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章