10

作为一个副项目,我目前正在为我曾经玩过的一个古老的游戏编写服务器。我试图使服务器尽可能松耦合,但我想知道多线程的一个好的设计决策是什么。目前,我有以下一系列操作:

  • 启动(创建)->
  • 服务器(监听客户端,创建)->
  • 客户端(监听命令并发送周期数据)

我假设平均有 100 个客户,因为这是游戏在任何给定时间的最大值。对于整个事情的线程化,正确的决定是什么?我目前的设置如下:

  • 服务器上的 1 个线程侦听新连接,在新连接上创建一个客户端对象并再次开始侦听。
  • 客户端对象有一个线程,监听传入的命令并定期发送数据。这是使用非阻塞套接字完成的,因此它只是检查是否有可用数据,处理它,然后发送它已排队的消息。登录在发送-接收周期开始之前完成。
  • 一个线程(目前)用于游戏本身,因为我认为从架构上讲,它与整个客户端-服务器部分是分开的。

这将导致总共 102 个线程。我什至正在考虑给客户端 2 个线程,一个用于发送,一个用于接收。如果我这样做,我可以在接收线程上使用阻塞 I/O,这意味着线程在一般情况下大部分是空闲的。

我主要担心的是,通过使用这么多线程,我将占用资源。我不担心比赛条件或死锁,因为无论如何我都必须处理这些事情。

我的设计是这样设置的,即我可以使用单个线程进行所有客户端通信,无论它是 1 还是 100。我已经将通信逻辑与客户端对象本身分离,因此我可以实现它而无需重写很多代码。

主要问题是:在应用程序中使用超过 200 个线程是错误的吗?它有优点吗?我正在考虑在多核机器上运行它,这样会充分利用多核吗?

谢谢!


在所有这些线程中,它们中的大多数通常会被阻塞。我预计连接数不会超过每分钟 5 个。来自客户端的命令很少会出现,我会说平均每分钟 20 个。

按照我在这里得到的答案(上下文切换是我正在考虑的性能影响,但在你指出之前我不知道,谢谢!)我想我会选择一个听众的方法,一个接收者、一个发送者和一些杂项;-)

4

5 回答 5

8

使用事件流/队列和线程池来保持平衡;这将更好地适应可能具有更多或更少内核的其他机器

一般来说,比核心多得多的活动线程会浪费时间上下文切换

如果您的游戏包含许多短动作,循环/循环事件队列将比固定数量的线程提供更好的性能

于 2008-12-17T17:44:54.093 回答
5

简单地回答这个问题,在今天的硬件上使用 200 个线程是完全错误的。

每个线程占用 1 MB 内存,因此您在开始做任何有用的事情之前就占用了 200 MB 的页面文件。

无论如何,将您的操作分解成可以在任何线程上安全运行的小块,但将这些操作放在队列中,并有固定的、有限数量的工作线程为这些队列提供服务。

更新:浪费 200MB 重要吗?在 32 位机器上,它是进程的整个理论地址空间的 10% - 没有其他问题。在 64 位机器上,这听起来像是理论上可用的沧海一粟,但实际上它仍然是一个非常大的存储块(或者更确切地说,是大量相当大的存储块),被应用程序,然后必须由操作系统管理。它的效果是用大量无价值的填充物包围每个客户端的有价值信息,这会破坏局部性,挫败操作系统和 CPU 将经常访问的内容保存在最快的缓存层中的尝试。

无论如何,内存浪费只是精神错乱的一部分。除非您有 200 个内核(以及能够使用的操作系统),否则您实际上并没有 200 个并行线程。你有(比如说)8 个内核,每个内核都在 25 个线程之间疯狂地切换。您可能会天真地认为,因此,每个线程都相当于在慢 25 倍的内核上运行。但实际上比这更糟糕 - 操作系统花费更多时间从核心中取出一个线程并在其上放置另一个线程(“上下文切换”),而不是实际允许您的代码运行。

看看任何著名的成功设计是如何解决这类问题的。CLR 的线程池(即使您不使用它)就是一个很好的例子。它开始假设每个核心只有一个线程就足够了。它允许创建更多,但只是为了确保最终完成设计糟糕的并行算法。它拒绝每秒创建超过 2 个线程,因此它通过减慢线程贪婪算法来有效地惩罚它们。

于 2008-12-17T18:24:48.833 回答
4

我用 .NET 编写代码,我不确定我的编码方式是否是由于 .NET 的限制和它们的 API 设计,或者这是否是一种标准的做事方式,但这就是我在过去:

  • 将用于处理传入数据的队列对象。这应该在排队线程和工作线程之间同步锁定以避免竞争条件。

  • 用于处理队列中数据的工作线程。排队数据队列的线程使用信号量通知该线程处理队列中的项目。该线程将在任何其他线程之前自行启动,并包含一个可以运行的连续循环,直到它收到关闭请求。循环中的第一条指令是暂停/继续/终止处理的标志。该标志最初将设置为暂停,以便线程处于空闲状态(而不是连续循环),而无需进行任何处理。当队列中有要处理的项目时,排队线程将更改标志。然后,该线程将在循环的每次迭代中处理队列中的单个项目。

  • 一个连接侦听器线程,它侦听传入的连接请求并将其传递给...

  • 创建连接/会话的连接处理线程。从您的连接侦听器线程中拥有一个单独的线程意味着您正在减少由于该线程处理请求时资源减少而丢失连接请求的可能性。

  • 一个传入数据侦听器线程,用于侦听当前连接上的传入数据。所有数据都被传递到排队线程以进行排队处理。您的侦听器线程应在基本侦听和传递数据以进行处理之外尽可能少地做。

  • 一个队列线程,它以正确的顺序对数据进行排队,以便可以正确处理所有内容,该线程将信号量提升到处理队列,让它知道有数据要处理。将此线程与传入数据侦听器分开意味着您不太可能错过传入数据。

  • 在方法之间传递的一些会话对象,以便每个用户的会话在整个线程模型中都是自包含的。

这使线程变得像我想出的那样简单但健壮的模型。我很想找到一个比这更简单的模型,但我发现如果我尝试进一步减少线程模型,我会开始丢失网络流上的数据或丢失连接请求。

它还有助于 TDD(测试驱动开发),这样每个线程都在处理单个任务,并且更容易编写测试代码。拥有数百个线程会很快成为资源分配的噩梦,而拥有单个线程则成为维护的噩梦。

为每个逻辑任务保留一个线程要简单得多,就像在 TDD 环境中每个任务有一个方法一样,您可以在逻辑上分离每个任务应该做什么。发现潜在问题更容易,解决问题也容易得多。

于 2008-12-17T18:05:04.897 回答
2

你的平台是什么?如果是 Windows,那么我建议您查看异步操作和线程池(如果您在 C/C++ 中的 Win32 API 级别工作,则直接查看 I/O 完成端口)。

这个想法是您有少量线程来处理您的 I/O,这使您的系统能够扩展到大量并发连接,因为连接数和进程使用的线程数之间没有关系那是为他们服务。正如预期的那样,.Net 将您与细节隔离开来,而 Win32 则没有。

使用异步 I/O 和这种服务器风格的挑战在于,客户端请求的处理变成了服务器上的状态机,并且到达的数据会触发状态更改。有时这需要一些时间来适应,但一旦你这样做了,它真的很了不起;)

我有一些免费代码,在此处演示了使用 IOCP 的 C++ 中的各种服务器设计。

如果您使用的是 unix 或需要跨平台并且您使用的是 C++,那么您可能需要查看提供异步 I/O 功能的 boost ASIO。

于 2008-12-17T18:13:41.023 回答
0

我认为您应该问的问题不是 200 作为一般线程数是好是坏,而是这些线程中有多少是活动的。

如果在任何给定时刻只有其中几个处于活动状态,而所有其他人都在睡觉或等待或诸如此类,那么你很好。在这种情况下,休眠线程不会花费您任何费用。

但是,如果所有这 200 个线程都处于活动状态,那么您的 CPU 将浪费大量时间在所有这 200 个线程之间进行线程上下文切换。

于 2008-12-17T17:49:37.687 回答