一个单线程的回声服务器 (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和注2)
等待客户端连接(注4)
接收客户端发送的数据(注5)
发送回复(注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 的连接数量就更少了。
(未完待续)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。