一个简单的 golang ping/pong HTTP 服务都经过了哪些系统调用

1,087 阅读6分钟

实验环境

不同版本的 golang/linux 内核, 对应的内核调用(细节)可能会不一行

环境以及使用的工具:

  1. centos 8.2.2004

  2. go 1.15.3

  3. objdump 2.30-73

  4. strace

  5. nm

  6. nc

还有shell 工具, 这里我用的是 xshell

顺手验证一下, 编译器是怎么实现c++的函数重载

  1 #include<iostream>
  2 
  3 using std::cout;
  4 using std::cin;
  5 using std::endl;
  6 
  7 double sum(double a, double b) {
  8         return a + b;
  9 }
 10 
 11 float sum(float a, float b) {
 12         return a + b;
 13 }
 14 
 15 int sum(int a, int b) {
 16         return a + b;
 17 }
 18 
 19 char sum(char a, char b) {
 20         return a + b;
 21 }
 22 
 23 long sum(long a, long b) {
 24         return a + b;
 25 }
 26 
 27 using ll = long long;
 28 
 29 ll sum(ll a, ll b) {
 30         return  a + b;
 31 }
 32 
 33 int main(void) {
 34         // todo 
 35         return 0;                       
 36 }                       
 37         

重载的函数名字叫做 sum, 使用 nm 分析可执行文件, 分析符号表,

[root@192 src]# nm a.out  | grep  -C 0 sum
0000000000400785 t _GLOBAL__sub_I__Z3sumdd
--
00000000004006ee T _Z3sumcc
00000000004006a6 T _Z3sumdd
00000000004006c0 T _Z3sumff
00000000004006da T _Z3sumii
000000000040070a T _Z3sumll
0000000000400723 T _Z3sumxx

可以得到, 对于不同的参数类型, 函数名称也发生了对应的变化, 比如 两个参数都是 char 的, 符号是 _Z3sumcc ...., 其实重载在编译器层面是通过传入参数的不同类型来作为(整个函数的)标识符的(通过nm 工具分析得到的);之前感觉都被骗了, 分析了之后才知道, 原来如此 😳 (有被骗到的请给我点个👍好么)

实现简单的 http ping/pong 服务器

package main

import (
    "bytes"
    "fmt"
    "net"
    "time"
)

func main() {
    
    listener, err := net.Listen("tcp", ":8080") // 绑定 8080 端口号, 通知系统把和 8080 端口相关的请求转发给这个进程
    if err != nil {
        panic(err)
    }
    
    // 系统调用, 向标准输出写字符串
    fmt.Println("simple... ping pong")
    defer listener.Close()
    
    for {
        // 阻塞监听事件
        c, err := listener.Accept()
        
        if err != nil {
            fmt.Println("error ", err)
            continue
        }
        go consumer(c)
    }
}

// 消费客户端的请求
func consumer(conn net.Conn) {
    
    // HTTP 信息
    // 1. 请求行
    // 2. 请求头
    // 3. 请求头体(可能有)
    start := time.Now()
    var buff = bytes.Buffer{}
    
    body := fmt.Sprintf(`{"ping":"pong","time":"%s"}`, time.Now().String()) // 响应体
    buff.WriteString("HTTP/1.1 200 OK\r\n")                                 // 响应行,
    buff.WriteString("Content-Type:application/json\r\n")                   // 响应头
    buff.WriteString(fmt.Sprintf("Content-Length:%d\r\n", len(body)))
    
    buff.WriteString("\r\n") // 空一行
    
    buff.WriteString(body)
    conn.Write(buff.Bytes())
    
    fmt.Println("--- new client ----", conn.RemoteAddr().Network(), conn.RemoteAddr().String(), " ", string("data"), time.Now().Sub(start))
    conn.Close() // 关闭文件(关闭链接)
}


[root@192 src]# ll -h
total 2.9M
-rw-r--r--. 1 root root  409 Nov  5 15:38 hello.c
-rwxr-xr-x. 1 root root 2.9M Nov  5 17:02 main
-rw-r--r--. 1 root root 1.5K Nov  5 17:02 main.go
[root@192 src]# ./main 
精简版本的 http 服务器... ping pong
--- new client ---- tcp [::1]:58298   data 108.825µs


可以看到, 自带运行时的程序还是挺大的, 居然有 2.9mb, 不过, 这个并不是我们关心的重点信息

使用 netcat 请求一下, 验证程序是否可以正常工作

[root@192 src]# nc  localhost 8080
HTTP/1.1 200 OK
Content-Type:application/json
Content-Length:79

{"ping":"pong","time":"2020-11-05 17:05:40.415831503 +0800 CST m=+7.840433991"}

完美, 看到完整的 http 响应信息

服务程序完成网络请求, 调用了哪些系统函数呢?

  1. 使用 strace 来追踪系统的调用记录 strace -ff -o output ./main
-ff: 表示 follow forks with output into separate files
-o: 表示 send trace output to FILE instead of stderr (原来默认是标准错误输出流的数据, 😳)
output: 表示输出文件名的前缀信息
./main: 程序的路径
  1. 然后使用 netstat 命令过滤出进程的 id

这里监听的端口是 8080, 进程 id 是 14082

[root@192 src]# netstat -antp | grep 8080
tcp6       0      0 :::8080                 :::*                    LISTEN      14082/./main   
  1. 分析 strace 输出的日志文件

这里是 output.13688 文件里面的内容

文件里面的第一行是 execve("./main", ["./main"], 0x7ffc1ee43cb8 /* 31 vars */) = 0 可以看到是调用 execve 函数把程序运行起来的

  1. net.Listen 做了啥?
    listener, err := net.Listen("tcp", ":8080")

对应的 linux 内核调用如下(省略了一大半的内容):

// .... 
epoll_ctl(5, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3692773144, u64=140049691393816}}) = 0
getsockname(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28]) = 0

其中主要就是: bind(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0 8080 端口和 3 绑定起来了, 8080 是程序监听的端口信息, 3 是 sock 的文件描述信息

bind 的函数原型如下 (使用 man 2 bind 可以获取帮助文档):

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

struct sockaddr 的对象信息如下, golang 里面的语句信息 net.Listen("tcp", ":8080"):

{sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}

fmt.Println("simple... ping pong") 对应的系统调用是啥?

write(1, "simple... ping pong\n", 20)   = 20

等到程序执行的之后, 实际上会调用内核函数向文件描述为 3 的文件写入 "simple... ping pong\n", 写入的长度为 20,

还有重要的信息

for {
    // 阻塞监听事件
    c, err := listener.Accept()

    if err != nil {
        fmt.Println("error ", err)
        continue
    }
    go consumer(c)
}

代码中的 listener.Accept() 这里是阻塞型的, listener.Accept() 这里的阻塞型监听中, 获取 sock_fd 的信息, 有可能获取不到

accept4(3, {sa_family=AF_INET6, sin6_port=htons(62425), inet_pton(AF_INET6, "::ffff:192.168.145.1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 4

accept4 函数的返回值: RETURN VALUE On success, these system calls return a nonnegative integer that is a file descriptor for the accepted socket. On error, -1 is returned, and errno is set appropriately.

func consumer(conn net.Conn) {

  // HTTP 信息
  // 1. 请求行
  // 2. 请求头
  // 3. 请求头体(可能有)
  start := time.Now()
  var buff = bytes.Buffer{}

  body := fmt.Sprintf(`{"ping":"pong","time":"%s"}`, time.Now().String()) // 响应体
  buff.WriteString("HTTP/1.1 200 OK\r\n")                                 // 响应行,
  buff.WriteString("Content-Type:application/json\r\n")                   // 响应头
  buff.WriteString(fmt.Sprintf("Content-Length:%d\r\n", len(body)))

  buff.WriteString("\r\n") // 空一行

  buff.WriteString(body)
  conn.Write(buff.Bytes())

  fmt.Println("--- new client ----", conn.RemoteAddr().Network(), conn.RemoteAddr().String(), " ", string("data"), time.Now().Sub(start))
  conn.Close() // 关闭文件(关闭链接)
}

consumer 函数的系统调用有, 如下图所示的内容

整个服务程序的逻辑如下:

总结

哦, 对了, golang 里面的 time.Now() 函数, 会调用系统函数openat(AT_FDCWD, "/etc/localtime", O_RDONLY) 🐻

以上内容都是在 linux 上分析的, 其中还有很多的没有分析, 不是搞不动了, 而是内容太多了