从简单到高并发服务器(一)

浏览: 50 发布日期: 2016-08-17 分类: unix

一个单线程的回声服务器 (Echo Server)

我们从一个简单的服务器开始说起。
它可以接受一个客户的连接,接收消息,然后把这个消息发送回去,关闭连接——完工。我们用 Linux 和 iOS / OSX 上都通用的 BSD Socket 来编写这个服务器的代码。主体部分大概是这样的:(C++ 语法)

#include <stdio.h>
#include <stdlib.h>

#include <netdb.h>
#include <netinet/in.h>

#include <unistd.h>
#include <string.h>

void fuck_you(void)
{
    exit(EXIT_FAILURE);
};


int main(int argc, char *argv[])
{
    int server_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

    struct sockaddr_in server_addr;
    struct sockaddr_in client_addr;
    
    int portno = 5432;
    
    bzero((char *) &server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(portno);

    // 注1: 将 socket 绑定到具体的 IP 地址
    int bind_succ = bind
        ( server_sock
        , (struct sockaddr *) &server_addr
        , sizeof(server_addr)
        );
        
    if (bind_succ < 0) fuck_you();

    // 注2: 开始监听
    int listen_succ = listen(server_sock, 1024);

    if (listen_succ < 0) fuck_you();

    // 注3: 这是一个永远不会停止的循环
    while(true)
    {
        int client_addr_size;
        
        printf("before accept.\n");
        
        // 注4: 调用 accept 等待传入连接的时候会阻塞 (the thread is blocked here!)
        int client_sock = accept
            ( server_sock                       // 从这个服务器 socket 上面接受连接
            , (struct sockaddr *) &client_addr  // 这里获得发起连接的客户端的地址
            , (socklen_t*)& client_addr_size
            );
            
        printf("handle client sock: %d", client_sock);
            
        // 准备用于接收消息的缓冲区
        const int buffer_size = 1024;
        char * recv_buffer = (char*)malloc(buffer_size);
        
        printf("before recv, buffer ready, address: %p\n", recv_buffer);
            
        // 注5: 调用 recv 来接收客户端发送的信息,这个过程会阻塞
        int msg_size = recv
            ( client_sock     // 注意,是用 client_sock 来接收
            , recv_buffer     // 将接收到的内容放到缓冲区
            , buffer_size     // 告诉系统我们设置的缓冲区有多大
            , 0
            );
            
        fwrite(recv_buffer, sizeof(char), msg_size, stdout);
            
        // 注6: 把接收到的信息原样发送出去
        int byte_sent = send
            ( client_sock
            , recv_buffer
            , msg_size
            , 0
            );
            
        free(recv_buffer);
        // 注7: 主动断开客户端的连接
        close(client_sock);
    }  
}

这段代码当然是很粗糙(误:粗口),可能会有内存泄漏,如果客户发送的消息过长会接收不完全……各种各样的问题,但是它基本上呈现出了一个服务器程序到底是怎样运作的。

以下是代码中提到的,要实现一个TCP服务器几个重要的工作:

  1. 绑定监听地址,并开始监听(注1和注2)

  2. 等待客户端连接(注4)

  3. 接收客户端发送的数据(注5)

  4. 发送回复(注6)

实际上以上这四点也是任何服务器都要完成的事情。
如果是使用 Udp 的话,则不需要等待客户端连接这个步骤,这是因为 Udp 是面向数据包而不是面向连接的传输协议;而使用 Tcp 则需要等待客户端连接,实际上还会涉及到“三路握手” (3-way handshake) 这个建立 Tcp 连接的过程。
但是这个握手过程,由于是属于 TCP 协议的标准部分,因此实际上是由操作系统来帮助我们完成的(所有支持 TCP/IP 协议栈的操作系统都会替程序员完成这个过程)。我们只需要通过调用 accept 这个API,就相当于告诉系统“现在开始帮我处理握手这个事情,有人找你握手了再来告诉我吧”。

线程与阻塞

握手过程调用 accept 会阻塞整个程序的执行,阻塞是什么意思呢?
如果我们写代码的时候,写一个死循环,就如代码中 注3 那样:

while(true)
{
    printf("I just can't stop speaking!\n");
}

即使不运行这个程序,你也应该可以预料到,在屏幕上会不断打出一行行的内容。这说明,程序没有被阻塞的情况下,就会一直执行下去。严格来说,printf 也会阻塞,只不过阻塞的时间非常短,并且可以自动解除阻塞状态,具体的解释以后再说。

而调用 accept 就不可以自动解除阻塞状态了——如果你成功运行刚才的代码,你会看到,屏幕输出了 before accept. 之后,并没有马上接着输出 handle client sock: ——程序一直停留在 accept 被调用的地方,也可以认为是 accept 一直没有返回结果。

阻塞的本质是,操作系统把执行你的代码的线程暂停了,而线程则是操作系统安排CPU调度的基本单位,这通常意味着操作系统把 CPU 拿去干其他事情了,而你的程序不能使用 CPU进行计算,只能暂停。直到有一个客户成功连接到你的服务器为止。

为了模拟这个事情,我们可以使用 python + gevent 来模拟很多(300)个客户端并发地不停发起TCP连接:

from __future__ import print_function
from gevent.socket import socket as gsocket
import gevent
import socket


def do_connect(addr, index):
    if 0: client_sock = socket.socket()
    while True:
        client_sock = gsocket(socket.AF_INET,
                              socket.SOCK_STREAM,
                              socket.IPPROTO_TCP)
        print(addr)
        client_sock.connect(addr)
        print('client {0} connected.'.format(index))
        gevent.sleep(10)
        client_sock.send('Hello World')
        data = client_sock.recv(1024)
        print('recv data: {0}'.format(data))


if __name__ == '__main__':
    server_addr = ('127.0.0.1', 5432)
    greenlets = list()
    for i in xrange(300):
        g = gevent.spawn(do_connect, server_addr, i)
        greenlets.append(g)
    gevent.joinall(greenlets)
    

然后,如无意外,你就可以看到程序继续得到执行,非常有规律地重复,并且总是按顺序地一个连接一个连接地处理。如果注意到客户端的输出话你可能会看到,在后面的发起的连接都超时了,会看到很多 Traceback。

这肯定不是我们日常访问网站所能得到的体验:很快就可以连接上并且看到网页的内容(当然,在天朝,例外有很多)。所以这不是理想的高并发服务器。

为什么比较早发起连接的客户端不会超时,而后面发起的会超时呢?原因就是服务器端在阻塞等待 IO 的时候,单线程无法响应其他请求。

为了验证这个结论,你可以把客户端代码中,发送数据前 gevent.sleep 的时间加长,例如改为20 秒,你会发现更多的连接会超时—— 因为服务器花费在等待客户端发送数据的时间更多了,那么在相同超时时间前服务窗口内能够 accept 的连接数量就更少了。

(未完待续)

返回顶部