【问题标题】:Relative merits between one thread per client and queuing thread models for a threaded server?每个客户端一个线程和线程服务器的队列线程模型之间的相对优点?
【发布时间】:2016-05-30 23:18:53
【问题描述】:

假设我们正在构建一个线程服务器,旨在在具有四个内核的系统上运行。我能想到的两种线程管理方案是每个客户端连接一个线程和一个排队系统。

正如第一个系统的名称所暗示的那样,我们将为每个连接到我们服务器的客户端生成一个线程。假设一个线程始终专用于我们程序的主执行线程,我们将能够同时处理多达三个客户端,并且对于任何更多的并发客户端,我们将不得不依靠操作系统的抢先式多任务功能来切换它们(或者在绿色线程的情况下是虚拟机)。

对于我们的第二种方法,我们将创建两个线程安全队列。一种用于传入消息,一种用于传出消息。换句话说,请求和回复。这意味着我们可能会有一个线程接受传入连接并将它们的请求放入传入队列中。一两个线程将处理传入请求,解析适当的回复,并将这些回复放在传出队列中。最后,我们将有一个线程从该队列中取出回复并将它们发送回客户端。

这些方法的优缺点是什么?请注意,我没有提到这是哪种服务器。我假设哪个具有更好的性能配置文件取决于服务器是处理像 Web 服务器和 POP3 服务器这样的短连接,还是像 WebSocket 服务器、游戏服务器和消息应用服务器这样的长连接。

除了这两种之外,还有其他线程管理策略吗?

【问题讨论】:

  • 这实际上取决于您打算拥有多少客户以及您的请求处理过程中发生了什么。每个连接的线程很容易实现,但根本无法扩展
  • 假设您可以访问(或愿意创建)一个像样的线程池类,那么真的没有理由使用每个客户端的线程。线程池将以相同的复杂性级别为您提供相同的行为,但可扩展性更好。

标签: multithreading


【解决方案1】:

我相信我曾经做过这两个组织。


方法一

就像我们在同一页面上一样,第一个让主线程执行listen。然后,在一个循环中,它执行accept。然后它将返回值传递给pthread_create,并且客户端线程的循环执行recv/send in loop 处理远程客户端想要的所有命令。完成后,它会清理并终止。

有关此示例,请参阅我最近的回答:multi-threaded file transfer with socket

这具有主线程和客户端线程直接且独立的优点。没有线程等待另一个线程正在做的任何事情。没有线程正在等待它不需要的任何东西。因此,客户端线程 [复数] 都可以以最大线速运行。此外,如果一个客户端线程在recvsend 上被阻塞,而另一个线程可以去,它就会去。它是自我平衡的。

所有线程循环都很简单:wait for input, process, send output, repeat。连主线程也很简单:sock = accept, pthread_create(sock), repeat

另一件事。客户端线程与其远程客户端之间的交互可以是任何他们同意的。任何协议或任何类型的数据传输。


方法二

这有点类似于 N 工人模型,其中 N 是固定的。

因为accept [通常] 是阻塞的,我们需要一个类似于方法 1 的主线程。除了启动一个新线程,它需要 malloc 一个控制结构 [或其他一些mgmt scheme] 并将套接字放入其中。然后它将它放在客户端连接列表中,然后循环回accept

除了N个工作线程,你是对的。至少两个控制线程,一个做select/pollrecvenqueue request,一个做wait for resultselect/pollsend

需要两个线程来防止其中一个线程不得不等待两个不同的事情:各种套接字[作为一个组]和来自各种工作线程的请求/结果队列。对于单个控制线程,所有操作都必须是阻塞的,并且线程会像疯了一样旋转。

这是线程外观的[极其]简化版本:

// control thread for recv:
while (1) {
    // (1) do blocking poll on all client connection sockets for read
    poll(...)

    // (2) for all pending sockets do a recv for a request block and enqueue
    //     it on the request queue
    for (all in read_mask) {
        request_buf = dequeue(control_free_list)
        recv(request_buf);
        enqueue(request_list,request_buf);
    }
}

// control thread for recv:
while (1) {
    // (1) do blocking wait on result queue

    // (2) peek at all result queue elements and create aggregate write mask
    //     for poll from the socket numbers

    // (3) do blocking poll on all client connection sockets for write
    poll(...)

    // (4) for all pending sockets that can be written to
    for (all in write_mask) {
        // find and dequeue first result buffer from result queue that
        // matches the given client
        result_buf = dequeue(result_list,client_id);
        send(request_buf);
        enqueue(control_free_list,request_buf);
    }
}

// worker thread:
while (1) {
    // (1) do blocking wait on request queue
    request_buf = dequeue(request_list);

    // (2) process request ...

    // (3) do blocking poll on all client connection sockets for write
    enqueue(result_list,request_buf);
}

现在,有几点需要注意。只有 一个 请求队列用于所有工作线程。 recv 控制线程没有尝试选择空闲[或未充分利用的]工作线程并加入线程特定队列[这是另一个需要考虑的选项]。

单个请求队列可能是最有效的。但是,也许并非所有工作线程都是平等的。有些可能最终会出现在具有特殊加速硬件的 CPU 内核 [或集群节点] 上,因此有些请求可能必须发送到特定线程。

而且,如果这样做了,线程可以做“工作窃取”吗?也就是说,一个线程完成了它的所有工作,并注意到另一个线程在它的队列[兼容]中有一个请求,但还没有启动。线程将请求出列并开始处理它。

这种方法有一个很大的缺点。请求/结果块 [大部分] 是固定大小的。我已经完成了一个实现,其中控件可以有一个用于“side/extra”有效负载指针的字段,该指针可以是任意大小。

但是,如果进行大型传输文件传输,无论是上传还是下载,尝试通过请求块传递这些零碎的东西并不是一个好主意。

在下载的情况下,工作线程可以在将结果排入控制线程之前临时篡夺套接字和send文件数据。

但是,对于上传的情况,如果worker试图在一个紧密的循环中进行上传,它将与recv控制线程发生冲突。工作人员必须 [以某种方式] 提醒控制线程在其轮询掩码中包含套接字。

这开始变得复杂了。

而且,所有这些请求/结果块入队/出队都有开销。

另外,两个控制线程是一个“热点”。系统的整个吞吐量取决于它们。

而且,套接字之间存在交互。在简单的情况下,recv 线程可以启动一对一套接字,但其他希望发送请求的客户端会延迟到 recv 完成。这是一个瓶颈。

这意味着所有recv 系统调用必须是非阻塞的[异步]。控制线程必须管理这些异步请求(即启动一个并等待异步完成通知,然后将请求排入请求队列)。

这开始变得复杂了。

这样做的主要好处是拥有大量并发客户端(例如 50,000),但将线程数保持在一个合理的值(例如 100)。

此方法的另一个优点是可以分配优先级并使用多个优先级队列


比较和混合

同时,方法 1 完成了方法 2 所做的所有事情,但以一种更简单、更健壮的方式 [而且,我怀疑是更高吞吐量的方式]。

在创建方法 1 客户端线程后,它可能会拆分工作并创建多个子线程。然后它可以像方法 2 的控制线程一样工作。事实上,它可以像方法 2 一样从固定的 N 个池中提取这些线程。

这将弥补方法 1 的弱点,即线程将进行大量计算。如果有大量线程都在做计算,系统就会被淹没。排队方法有助于缓解这种情况。客户端线程仍处于创建/活动状态,但它正在结果队列中休眠。

所以,我们只是把水弄混了一点。

任何一种方法都可以是“正面”方法,并且在下面有另一种方法的元素。

给定的客户端线程 [方法 1] 或工作线程 [方法 2] 可以通过打开 [yet] 到“后台”计算集群的另一个连接来分包其工作。可以使用任一方法管理集群。

因此,方法 1 更简单、更易于实施,并且可以轻松适应大多数工作组合。方法 2 可能更适合重型计算服务器来限制对有限资源的请求。但是,必须小心方法 2,以避免出现瓶颈。

【讨论】:

    【解决方案2】:

    我认为你的“第二种方法”没有经过深思熟虑,所以我会看看我是否可以告诉你我认为考虑这些事情最有用的方法。

    规则 1) 如果您的所有核心都忙于做有用的工作,您的吞吐量就会最大化。尽量让你的核心忙于做有用的工作。

    这些事情会让你无法让你的核心忙于做有用的工作:

    • 您让他们忙于创建线程。如果任务是短暂的,那么使用线程池,这样您就不会花费所有时间来启动和杀死线程。

    • 您让他们忙于切换上下文。现代操作系统非常擅长多线程,但如果你必须每秒切换工作 10000 次,那么开销就会增加。如果这对您来说是个问题,您将不得不考虑事件驱动架构或其他更有效的显式调度。

    • 您的作业阻塞或等待很长时间,并且您没有资源来运行足够的线程线程来保持核心忙碌。当您使用持久连接提供协议时,这可能会成为一个问题,这些协议大部分时间都无所事事,例如 websocket 聊天。您不想通过将其绑定到单个客户端来保持整个线程无所事事。您需要围绕它进行架构设计。

    • 除了 CPU 之外,您的所有工作都需要一些其他资源,而您在这方面遇到了瓶颈 - 这是另一天的讨论。

    所有这一切......对于大多数请求/响应类型的协议,将每个请求或连接传递给线程池,在请求期间为它分配一个线程,在大多数情况下很容易实现和执行。

    规则 2) 在吞吐量最大化的情况下(所有内核都非常繁忙),按照先到先得的原则完成工作可以最大限度地减少延迟并最大限度地提高响应速度。

    这是事实,但在大多数服务器中根本不考虑它。当您的服务器很忙并且作业必须停止(即使是很短的时间)以执行大量阻塞操作时,您可能会在此处遇到麻烦。

    问题是没有什么可以告诉操作系统线程调度程序哪个线程的作业首先进入。每次您的线程阻塞然后准备就绪时,它都会与所有其他线程同等安排。如果服务器很忙,这意味着处理您的请求所花费的时间与它阻塞的次数大致成正比。这通常不好。

    如果您在处理作业的过程中必须阻塞很多,并且希望最大限度地减少每个请求的总体延迟,则您必须自己安排时间来跟踪哪些作业首先开始。例如,在事件驱动的架构中,您可以优先处理较早开始的作业的事件。在流水线架构中,您可以优先考虑流水线的后期阶段。

    记住这两条规则,设计您的服务器以使您的核心忙于有用的工作,并首先做第一件事。这样您就可以拥有一个快速响应的服务器。

    【讨论】:

    • 每秒 10,000 次切换作业仍然可以为每个时间片提供数十万个时钟周期。您需要每秒切换数百万次才能成为主要问题。
    猜你喜欢
    • 2015-03-24
    • 2021-03-10
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-11-10
    • 1970-01-01
    相关资源
    最近更新 更多