Windows Sockets 流 第 II 部分 - 多线程 TCP 服务器





5.00/5 (1投票)
用 20 行代码或更少实现一个多线程服务器
引言
不久前,我写了一篇文章,描述了一系列类,旨在简化在使用 Windows Sockets 时遇到的各种棘手问题。现在是时候扩展这些内容并加以利用了。本系列的下一章可能会更有趣,因为它将展示如何构建一个可嵌入的 HTTP 服务器,但在那之前,我们首先需要了解如何创建一个简单的 TCP 服务器。
背景
首先,快速回顾一下上一篇文章:它介绍了 sock
类,这是一个方便封装 Windows Sockets 的类。它提供了在处理 Sockets 时需要调用的绝大多数函数。此外,它也是一个良好的 C++ 设计,拥有复制构造函数、赋值运算符等。这将成为我们 TCP 服务器对象的基本构建块。
TCP 服务器通过调用 listen
函数来监听一个 Socket。当客户端连接到服务器时,accept
函数会返回另一个 Socket,服务器可以通过这个新创建的 Socket 与客户端进行通信。一个设计良好的服务器可能会继续以某种方式服务于原始 Socket。如果不是这样,尝试连接到它的其他客户端将无法及时得到服务。一种常见的架构(绝非唯一的一种)是使用一个线程监听主 Socket,并将其他线程分派出去处理与每个单独客户端的通信。我们的 tcpserver
类采用了这种架构。
tcpserver 对象
这是 tcpserver
类声明的开头部分
class tcpserver : public sock, public thread
{
public:
tcpserver (unsigned int max_conn=0, DWORD idle_time = INFINITE, const char *name = 0);
~tcpserver ();
...
它继承自 sock
(我们的 C++ Socket 包装器)和 thread
(Windows 线程的封装)。thread
类将在本文后面详细讨论。由于它继承自 sock
,因此您可以使用所有可用的函数来控制该 Socket 的行为。特别是,您可能需要将其绑定到一个接口和一个端口号。
继承自 thread
,该对象在启动后会创建一个新的执行线程,该线程会一直等待新连接。其他构造函数参数 max_conn
和 idle_time
分别指定允许的最大连接数(0 表示无限制连接)以及服务器线程等待新连接的最长时间(INFINITE
表示服务器将永远等待)。
本文代码附带一个小型示例,实现了一个回显服务器。这个服务器只是简单地等待客户端发送的行,并将它们完整地回显回去,包括换行符。从这个示例代码中,我们可以看到回显服务器是如何构建的。
tcpserver srv;
srv.bind (inaddr (INADDR_LOOPBACK, 12321));
...
srv.start ();
...
我们需要深入了解一下 tcpserv
的实现(在 tcpserver.cpp 文件中),看看当客户端连接到主服务器线程正在监听的端口时会发生什么。正如您可能想象的那样,每个 thread
对象(而 tcpserver
是一个线程)都有一个 run()
函数,该函数负责大部分工作。去除冗余部分,tcpserver
的 run()
函数片段如下所示:
if (is_readready(0))
{
/// - check if there is space in connections table
if (limit && count >= limit)
{
//too many connections
s.close ();
continue;
}
contab_lock.enter ();
/// - find an empty slot in connections table
...
inaddr peer;
contab[i] = new conndata;
contab[i]->socket = accept (peer);
...
/// - invoke make_thread to get a servicing thread
contab[i]->thread = make_thread (contab[i]->socket);
...
/// - invoke initconn function
initconn (contab[i]->socket, contab[i]->thread);
contab_lock.leave ();
翻译成英文,这意味着服务器维护一个活动连接表,当有新客户端连接时,它会调用一个虚拟函数 make_thread
来创建一个新线程来处理该连接。由这个线程来执行任何它认为合适的任务。一个简单的回显服务器只会返回从客户端接收到的行,而一个 HTTP 服务器将实现 HTTP 协议。
这种设计意味着您需要从 tcpserver
派生出自己的类,并实现自己的 make_thread
函数,以实现特定的服务器。然而,对于简单的服务器(如我们的回显服务器),有一个捷径:您可以使用 set_connfunc()
函数来传递一个函数指针或 lambda 表达式,服务器将创建一个以该函数为体的线程。set_connfunc
方法的签名是:
void set_connfunc (std::function<int (sock&)>f);
它接收一个客户端 Socket 的引用(由 accept()
函数返回),并负责完成与客户端的整个会话。
这是我们回显服务器的完整实现:
int main (int argc, char** argv)
{
tcpserver srv;
srv.bind (inaddr (INADDR_LOOPBACK, 12321));
srv.set_connfunc (
[](sock& conn)->int {
sockstream strm (conn);
std::string line;
//echo each line
while (getline (strm, line))
strm << line << endl;
return 0;
}
);
srv.start ();
while (_kbhit ())
;
_getch ();
srv.terminate ();
return 0;
}
这就是全部内容了。
- 我们创建了服务器,并将主 Socket 绑定到一个端口。
- 连接函数是一个 lambda 表达式,它接收连接 Socket 作为参数。
- 它在该 Socket 上创建一个 Socket 流,并不断读取行(使用
getline
函数)。 - 每一行都会被发送回客户端。
- 当客户端关闭连接时,循环中断,线程终止。
- 启动服务器线程后,主线程等待按键。
- 当按下某个键时,服务器将被粗暴地关闭,整个程序停止。
在不到 20 行代码中,我们实现了一个功能齐全的多线程回显服务器。在 Rosetta Code 上,有一个页面展示了该服务器在不同编程语言中的实现。与其他语言相比,我们的实现看起来并不逊色。
Thread 类
这个类是用于封装基本 Windows 同步对象的类集合的一部分。我很清楚现在 C++ 标准库中已经有很多同步对象(包括线程)。在我编写这些类的时候,还没有这些便利的功能。即使是今天,我的封装仍然具有密切模仿 Windows API 函数的优点。所有这些类都继承自一个抽象基类 syncbase
,该类封装了一个 Windows handle,无论是线程 handle、事件 handle、信号量、互斥量等。
虽然大多数其他类只是对相应 Windows API 对象的薄封装,但 Thread 类要复杂一些。以下是 thread
对象最重要的几个方法:
public:
thread (std::function<int ()> func, const char *name=0);
virtual ~thread ();
virtual void start ();
...
protected:
thread (const char *name=0, bool inherit=false, DWORD stack_size=0,
PSECURITY_DESCRIPTOR sd=NULL);
/// Initialization function called before run
virtual bool init ();
/// Finalization function called after run
virtual bool term ();;
/// Thread's body
virtual void run ();
protected
构造函数允许您创建一个派生自 thread
的对象,该对象可以覆盖 run()
函数来实现线程的行为。
public
构造函数接受一个函数指针或 lambda 表达式,该函数或表达式成为线程的主体。在许多情况下,使用这个 public
构造函数比派生另一个对象更容易。缺点是您无法像通过 init()
和 term()
方法那样获得更精细的控制。
线程在“暂停”状态下创建。要让它们运行,您需要调用 start()
函数(不要混淆这两个函数:run()
代表线程的主体,start()
开始线程的执行)。
我们已经看到 tcpserver
继承自 thread
并实现了自己的 run()
函数。关于从 lambda 表达式创建的线程的一个例子,请看 tcpserver::make_thread()
函数的实现。
thread* tcpserver::make_thread (sock& connection)
{
if (connfunc)
{
auto f =
[&]()->int {
int ret = connfunc (connection);
close_connection (connection);
return ret;
};
return new thread (f);
}
return NULL;
}
如果您还记得,我们说过连接函数的签名是:
int f (sock& socket);
同时,线程构造函数需要一个签名如下的函数:
int f ();
lambda 表达式 f
负责调用 connfunc
并传递适当的 connection
参数。它还具有正确的签名,可以作为参数传递给 thread
构造函数。
最终想法
- 本文包含的代码是从我的 mlib 项目中截取的。虽然可以直接使用,但我强烈建议从 GitHub 获取整个库。
- 本系列还将有第三篇文章,将介绍可嵌入的 HTTP 服务器。敬请关注!
历史
- 2020 年 6 月 10 日 - 初始版本