【问题标题】:Using IOCP with UDP?将 IOCP 与 UDP 一起使用?
【发布时间】:2012-07-06 20:29:29
【问题描述】:

对于TCP,我非常熟悉输入/输出完成端口

但是,例如,如果我正在编写 FPS 游戏,或者任何需要低延迟的东西可能会破坏交易 - 我希望立即响应玩家以提供最佳的游戏体验,即使以损失一些空间为代价随时随地的数据。很明显,我应该使用 UDP 并且除了经常发送坐标更新之外,我还应该实现一种半可靠协议(afaik TCP 在 UDP 中会导致丢包,所以我们应该避免将这两者混合)来处理诸如聊天消息或丢包可能至关重要的枪击之类的事件。

假设我的目标是适用于 MMOFPS 游戏的性能,该游戏允许在一个持久的世界中遇到数百名玩家,并且除了与枪支战斗之外,它还允许他们通过聊天消息等进行交流 - 比如这确实存在并且运行良好 - 查看 PlanetSide 2。

网上的许多文章(例如来自 msdn 的文章)都说重叠套接字是最好的,而 IOCP 是神级概念,但它们似乎没有区分我们使用 TCP 以外的其他协议的情况。

所以几乎没有关于开发这样的服务器时使用的I/O技术的可靠信息,我看过this,但是这个话题似乎很有争议,我也看过this,但是考虑到第一个链接中的讨论,我不知道我是否应该遵循第二个的假设,我是否应该将 IOCP 与 UDP 一起使用,如果不是,什么是UDP 方面最具可扩展性和最高效的 I/O 概念

或者我只是在进行另一个过早的优化,暂时不需要提前考虑?

考虑将其发布在 gamedev.stackexchange.com 上,但我认为这个问题更适用于通用网络。

【问题讨论】:

  • 如果您提供更多有关您打算进行何种交流的背景知识可能会有所帮助?和谁之间?多常?等
  • @reuben 好的,添加了缩进。
  • 对于 TCP:每个客户端都有一个连接句柄。对于 UDP:没有连接,因此没有句柄。如果您有很多句柄,IOCP 很有用,因为恕我直言,它与 UDP 无关。相反,您可以在您的 UDP 套接字上进行选择,或者您只需调用 recvfrom,如果 errno = WillBlock/TryAgain 则没有什么可以接收...
  • @Malkacoglu 需要“可靠 UDP”的情况如何?我应该在最简单的 recvfroms/sendtos 之上为我的虚拟 UDP 连接实现我自己滚动的 IOCP 吗?
  • 您可能会发现一些有趣的事情Gaffer On Games(专业游戏开发人员的一组网络文章)和enet(游戏网络库,在 UDP 之上构建各种功能)。

标签: c++ performance networking udp iocp


【解决方案1】:

我不推荐使用这个,但技术上接收 UDP 数据报最有效的方法是阻塞recvfrom(或者WSARecvFrom,如果你愿意的话)。当然,你需要一个专门的线程,否则在你阻塞时不会发生太多事情。

除了 TCP,您确实没有在协议中内置了连接,并且您没有没有定义边界的流。这意味着您可以通过每个传入的数据报获得发件人的地址,并且您会收到完整的消息或什么也没有。总是。没有例外。
现在,阻塞recvfrom 意味着一个上下文切换到内核,一个上下文在收到某些内容时切换回来。通过在飞行中进行多次重叠读取也不会加快速度,因为只有一个数据报可以同时到达线路,这是迄今为止最大的限制因素(CPU 时间不是瓶颈!)。使用 IOCP 意味着至少有 4 个上下文切换,两个用于接收,两个用于通知。或者,带有完成回调的重叠接收也不会好多少,因为您必须 NtTestAlertSleepEx 才能运行 APC 队列,因此您至少有 2 个额外的上下文切换(尽管所有通知只有 +2在一起,而且你可能顺便已经睡着了)。

但是:
使用 IOCP 和重叠读取仍然是最好的方法,即使它不是最有效的方法。完成端口与使用 TCP 无关,它们也适用于 UDP。只要您使用重叠读取,您使用什么协议(甚至是网络或磁盘,或其他一些可等待或可警报的内核对象)都没有关系。
是否为完成端口额外燃烧几百个周期对于延迟或 CPU 负载也无关紧要。我们在这里谈论的是“纳米”与“毫”,相差一到一百万。另一方面,完成端口总体而言是一个非常舒适、健全和高效的系统。

例如,当您没有及时收到 ACK 时,您可以简单地实现重新发送的逻辑(当您需要某种形式的可靠性时,您必须这样做,而 UDP 不会为您这样做),以及keepalive。
对于 keepalive,添加一个可等待计时器(可能会在 15 或 20 秒后触发),您每次收到 anything 时都会重置该计时器。如果您的完成端口告诉您此计时器已关闭,则您知道连接已失效。
对于重新发送,您可以例如在GetQueuedCompletionStatus 上设置一个超时,每次你醒来都会发现所有比某某旧且尚未被确认的数据包。
整个逻辑发生在一个地方,这非常好。它用途广泛、高效且不易出错。

您甚至可以在完成端口上阻塞多个线程(实际上,线程数比 CPU 的内核数还多)。许多线程听起来像是一个不明智的设计,但实际上这是最好的做法。

完成端口以后进先出的顺序唤醒 N 个线程,N 是内核数,除非您告诉它执行不同的操作。如果这些线程中的任何一个阻塞,另一个会被唤醒以处理未完成的事件。这意味着在最坏的情况下,一个额外的线程可能会在短时间内运行,但这是可以容忍的。在平均情况下,只要有一些工作要做,它就会使处理器使用率接近 100%,否则为零,这非常好。 LIFO 唤醒有利于处理器缓存并保持低切换线程上下文。

这意味着您可以阻塞并等待传入​​的数据报并对其进行处理(解密、解压缩、执行逻辑、从磁盘读取某些内容等),另一个线程将立即准备好处理下一个可能出现的下一个数据报微秒。您也可以使用具有相同完成端口的重叠磁盘 IO。如果您有计算工作(例如 AI)可以拆分为任务,您也可以在完成端口上手动发布 (PostQueuedCompletionStatus) 并且您有一个免费的并行任务调度程序。您所要做的就是将OVERLAPPED 包装到一个结构中,该结构后面有一些额外的数据,并使用您将识别的密钥。不用担心线程同步,它只是神奇地工作(您甚至不需要严格在发布自己的通知时在自定义结构中包含OVERLAPPED,它适用于您传递的任何结构,但我不喜欢对操作系统撒谎,你永远不知道......)。

是否阻塞甚至都没有多大关系,例如从磁盘读取时。有时这只是发生,你无能为力。那又怎样,一个线程阻塞了,但您的系统仍然接收消息并对其做出反应!必要时,完成端口会自动从其池中拉出另一个线程。

关于 TCP 在 UDP 上导致数据包丢失,我倾向于称之为都市神话(尽管它有些正确)。然而,这种常见的口头禅的措辞方式具有误导性。曾几何时,路由器可能会丢弃 UDP 以支持 TCP,从而导致数据包丢失,这可能是真的(存在关于该问题的研究,但已有近十年的历史)。但是,如今的情况肯定不是这样。
更真实的观点是,您发送的任何东西都会导致丢包。 TCP 在 TCP 上导致丢包,而 UDP 在 TCP 上导致丢包,反之亦然,这是正常情况(顺便说一下,这就是 TCP 实现拥塞控制的方式)。如果另一个插头上的电缆“静默”,路由器通常会转发一个传入数据包,它会在硬期限内将几个数据包排队(缓冲区通常故意小),它可能会应用一些QoS 的形式,它会简单而无声地丢弃所有其他内容
现在,许多具有相当苛刻的实时要求的应用程序(VoIP、视频流,等等)都使用 UDP,虽然它们可以很好地处理一两个丢失的数据包,但它们根本不喜欢严重的、反复出现的数据包丢失。尽管如此,它们在具有大量 TCP 流量的网络上仍然可以正常工作。我的电话(就像数百万人的电话一样)完全通过 VoIP 工作,数据通过同一个路由器作为互联网流量传输。无论我多么努力,我都无法通过 TCP 引起辍学。
从日常观察中,可以确定 UDP 绝对不会被 TCP 抛弃。如果有的话,QoS 可能有利于 UDP 而不是 TCP,但它肯定不会惩罚它。
否则,一旦您打开网站,VoIP 之类的服务就会停顿,并且如果您下载 DVD ISO 文件大小的文件,那么整个服务将无法使用。

编辑:
为了让您了解使用 IOCP 的生活有多简单(有些精简,缺少实用功能):

for(;;)
{
    if(GetQueuedCompletionStatus(iocp, &n, &k, (OVERLAPPED**)&o, 100) == 0)
    {
        if(o == 0) // ---> timeout, mark and sweep
        {
            CheckAndResendMarkedDgrams();  // resend those from last pass
            MarkUnackedDgrams();           // mark new ones
        } 
        else
        {   // zero return value but lpOverlapped is not null:
            // this means an error occurred
            HandleError(k, o);
        }
        continue;
    }

    if(n == 0 && k == 0 && o == 0)
    {
        // zero size and zero handle is my termination message
        // re-post, then break, so all threads on the IOCP will
        // one by one wake up and exit in a controlled manner
        PostQueuedCompletionStatus(iocp, 0, 0, 0);
        break;
    }
    else if(n == -1) // my magic value for "execute user task"
    {
        TaskStruct *t = (TaskStruct*)o;
        t->funcptr(t->arg);
    }
    else
    {
        /* received data or finished file I/O, do whatever you do */
    }
}

注意处理完成消息、用户任务和线程控制的整个逻辑是如何在一个简单的循环中发生的,没有晦涩的东西,没有复杂的路径,每个线程只执行相同的循环。
相同的代码适用于服务 1 个套接字的 1 个线程,或服务于 5,000 个套接字的 50 个线程池中的 16 个线程、10 个重叠文件传输以及执行并行计算。

【讨论】:

  • +1,特别是指出 IOCP 可以同时处理网络和应用程序特定事件(如逻辑)这一事实。至于 TCP 和丢包,很高兴听到它不再是一个大问题。那么,您是否建议我应该在我的服务器应用程序中混合使用这两种协议,例如,UDP 用于时间敏感的空间数据、枪击等(不需要完全可靠性),而 TCP 用于聊天消息和初始世界之类的东西数据,无论如何我会尝试用 UDP 重新实现我自己的 TCP 吗?如果是这样,那么只有一个 CP 对这么多用途有好处吗?
  • 而且,在另一端额外拥有 TCP 连接的开销不会超过我自己实现的可靠性的缺点吗?如果没有,那就太好了,因为 TCP 会为我做 keepalives。
  • 您可以使用多个完成端口,但只使用一个就可以了。我一直在使用具有数百个句柄的单个完成端口,并每秒发布数千条用户消息,它工作得很好。我不担心拥有额外套接字的开销,只要没有发送/接收数据,开销就接近于零(分配了几千字节的缓冲内存,并且使用了零 CPU)。在启动时将 TCP 用于聊天消息和加载级别数据看起来是个好主意,这是 TCP 擅长的使用模式。关于可靠性等,您必须确定您到底需要什么。如果你...
  • ... 在 UDP 之上完全重新实现 TCP,您最好首先使用 TCP(禁用 Nagle)。另一方面,如果您不需要,例如严格的按顺序交付保证或可以忍受丢失某些消息(例如位置更新,如果它们被下一个数据包补偿),那么您当然可以使用 UDP 走捷径。但这是其他人无法回答的问题,这取决于您的设计。
  • 是的,但是……即使使用 TCP,您也必须自己处理 keepalive,至少对于游戏而言。虽然 TCP 确实有这样的功能,但它通常是不启用的,如果启用它,默认超时时间大约是 2 小时——所以它对你来说有点没用。这适用于 Web 服务器,但您想尽快了解丢失的连接,而不是几分钟或几小时后。因此,你总是想记录你上次发送和接收东西的时间,如果你没有其他东西要发送,你总是想定期(可能每 5 秒)发送一个“探测”。跨度>
【解决方案2】:

我见过许多使用 UDP 作为网络协议的 FPS 游戏的代码。

标准解决方案是在一个大的 UDP 数据包中发送更新单个游戏帧所需的所有数据。该数据包应包括帧号和校验和。数据包当然应该被压缩。

通常,UDP 数据包包含玩家附近每个实体的位置和速度、发送的任何聊天消息以及所有最近的状态变化。 (例如,创建新实体、销毁实体等)

然后客户端监听UDP数据包。它将仅使用具有最高帧号的数据包。因此,如果出现乱序数据包,旧数据包将被忽略。

任何校验和错误的数据包也会被忽略。

每个数据包都应包含客户端游戏状态与服务器同步的所有信息。

聊天消息通过多个数据包重复发送,并且每条消息都有一个唯一的消息 ID 例如,您重新传输相同的聊天消息一整秒的帧。如果客户在收到 60 次后错过了一条聊天消息 - 那么网络频道的质量太低而无法玩游戏。客户端将显示他们在 UDP 数据包中收到的任何消息,但它们的消息 ID 尚未显示。

对于正在创建或销毁的对象也是如此。所有创建或销毁的对象都有一个由服务器设置的唯一对象 ID。如果对象对应的对象 id 之前没有被操作过,则对象会被创建或销毁。

所以这里的关键是冗余发送数据,并将所有状态转换关键到服务器设置的唯一ID。

@edit:另一位发帖者提到,对于聊天消息,您可能希望在不同的端口上使用不同的协议。他们可能是正确的,这可能是最佳的。这适用于延迟不重要但可靠性更重要的消息类型,您可能希望打开不同的端口并使用 TCP。但我会把它留作以后的练习。对于你的游戏来说,一开始只使用一个通道当然更容易和更清晰,然后再找出多端口、多通道的变幻莫测以及它们的各种故障模式。 (例如,如果 UDP 通道正常工作,但聊天通道出现故障,会发生什么情况?如果您成功打开一个端口而不打开另一个端口怎么办?)

【讨论】:

    【解决方案3】:

    当我这样做 for a client 时,我们使用 ENet 作为基本可靠的 UDP 协议,并从头开始重新实现它以将 IOCP 用于服务器端,同时将免费提供的 ENet 代码用于客户端。

    IOCP 可以很好地与 UDP 配合使用,并且可以很好地与您可能正在处理的任何 TCP 连接集成(我们有 TCP、WebSocket 或 UDP 客户端连接以及服务器节点之间的 TCP 连接,并且能够将所有这些插入到同一个线程中)如果我们想要的话,游泳池很方便)。

    如果绝对延迟和 UDP 数据包处理速度是最重要的(它不太可能真的那么使用新的 Server 2012 RIO API 可能是值得的,但我还不相信(有关一些初步性能测试和一些示例服务器,请参阅here)。

    您可能希望考虑使用 GetQueuedCompletionStatusEx() 处理入站数据,因为它减少了每个数据报的上下文切换,因为您可以通过一次调用将多个数据报拉回。

    【讨论】:

    • +1 获取有关 GetQueuedCompletionStatusEx 的有趣注释。
    【解决方案4】:

    几件事:

    1) 作为一般规则,如果您需要可靠性,最好只使用 TCP。基于 UDP 的具有竞争力的甚至可能更出色的解​​决方案是可能的,但要正确并使其正常运行是极其困难的。人们在 UDP 之上实现可靠性的主要问题是适当的流量控制。如果您打算发送大量数据并希望它优雅地利用当前可用的带宽(随着路由条件不断变化),您必须具有流量控制。在实践中,除了 TCP 使用的本质上相同的算法之外,实施任何其他协议也可能对网络上的其他协议不友好。在实现该算法方面,您不太可能比 TCP 做得更好。

    2) 至于并行运行 TCP 和 UDP,如今已不像其他人所指出的那样令人担忧。有一次,我听说沿途过载的路由器偏向于在 TCP 数据包之前丢弃 UDP 数据包,这在某些方面是有道理的,因为无论如何都会重新发送丢弃的 TCP 数据包,而丢失的 UDP 数据包通常不会。也就是说,我怀疑这是否真的发生。特别是,丢弃 TCP 数据包会导致发送方减速,因此丢弃 TCP 数据包可能更有意义。

    TCP 可能会干扰 UDP 的一种情况是 TCP 本质上它的算法会不断地尝试越来越快,除非它到达丢失数据包的点,然后它会节流并重复该过程。由于 TCP 连接不断地碰到带宽上限,它导致 UDP 丢失的可能性与 TCP 丢失一样,理论上 TCP 流量似乎偶尔会导致 UDP 丢失。

    但是,即使您将自己的可靠机制放在 UDP 之上(假设您正确进行流量控制),您也会遇到这个问题。如果您想避免这种情况,您可以有意地在应用层限制可靠数据。通常在游戏中,可靠数据速率仅限于客户端或服务器实际需要发送可靠数据的速率,这通常远低于管道的带宽能力,因此无论是否存在干扰都不会发生。基于 TCP 或 UDP 可靠。

    如果您正在制作流媒体资产游戏,事情就会变得更加困难。对于像 FreeRealms 这样的游戏,资产是通过 HTTP/TCP 从 CDN 下载的,它会尝试使用所有可用带宽,这将增加主游戏通道(通常是 UDP)上的丢包率。我通常发现干扰足够低,我认为您不必过分担心。

    3) 至于 IOCP,我对它们的经验非常有限,但在过去进行过广泛的游戏联网后,我怀疑它们是否会在 UDP 的情况下增加价值。通常,服务器将有一个处理所有传入数据的 UDP 套接字。在连接了数百个用户的情况下,数据进入服务器的速率非常高。正如其他人所建议的那样,让后台线程在套接字上进行阻塞调用,然后将数据快速移动到队列中以供主应用程序线程获取是一个合理的解决方案,但有些不必要,因为实际上数据是这样进来的在负载下速度很快,当线程阻塞时睡眠没有多大意义。

    让我换一种说法,如果阻塞套接字调用轮询单个数据包,然后让线程休眠直到下一个数据包进来,那么当数据到达时,每秒将上下文切换到该线程数千次率变高了。无论是这样,还是在未阻塞的线程执行并清除数据时,已经有额外的数据准备好进行处理。相反,我更喜欢将套接字置于非阻塞模式,然后让后台线程以大约 100fps 的速度旋转处理它(根据需要在轮询之间休眠以达到帧速率)。以这种方式,套接字缓冲区将建立传入数据包 10 毫秒,然后后台线程将唤醒一次并批量处理所有数据,然后返回睡眠状态,从而防止无缘无故的上下文切换。然后我让同一个后台线程在它唤醒时执行其他与发送相关的处理。当数据量稍微高一点时,完全由事件驱动会失去很多好处。

    在 TCP 的情况下,情况就完全不同了,因为您需要一种有效的机制来确定传入数据来自数百个连接中的哪一个,并且即使定期轮询它们也非常缓慢。

    因此,在 UDP 的情况下,在其之上具有本地开发的 UDP 可靠机制,我通常有一个后台线程扮演与操作系统相同的角色......而操作系统从网络获取数据card 然后将其分发到内部的各种逻辑 TCP 连接进行处理,我的后台线程从单独的 UDP 套接字(通过定期轮询)获取数据并将其分发到我自己的内部逻辑连接对象进行处理。然后,这些内部逻辑连接将应用程序级别的数据包数据放入线程安全的主队列中,并用它们来自的逻辑连接进行标记。然后主应用程序线程处理该主队列,将数据包直接路由到与该连接关联的游戏级对象。从主应用程序线程的角度来看,它只是有一个正在处理的事件驱动队列。

    底线是,鉴于对单独 UDP 套接字的轮询调用很少出现空,很难想象会有更有效的方法来解决这个问题。使用此方法唯一会丢失的是您等待长达 10 毫秒才能唤醒,而理论上您可能会在数据刚到达的那一刻醒来,但这仅在您处于极轻负载的情况下才有意义。另外,无论如何,主应用程序线程在下一个帧周期之前都不会使用数据,所以区别是没有意义的,我认为这种技术可以提高整体系统性能。

    【讨论】:

      【解决方案5】:

      我不会把像 PlanetSide 这样古老的游戏作为现代网络实施的典范。尤其是没有看到他们的网络库的内部。 :)

      不同类型的沟通需要不同的方法。上面的答案之一谈到了帧/位置更新和聊天消息之间的差异,但没有意识到为两者使用相同的传输可能很愚蠢。您绝对应该在您的聊天实现和聊天服务器之间使用连接的 TCP 套接字,以进行文本式聊天。不争辩,去做吧。

      因此,对于您的游戏客户端通过到达的 UDP 数据包进行更新,从网络适配器通过内核进入您的应用程序的最有效路径(很可能)将是阻塞接收。创建一个线程,从网络中提取数据包,验证它们的有效性(chksum 匹配,序列号增加,无论你有什么其他检查),将数据反序列化为内部对象,然后将内部队列中的对象排队到应用程序线程处理这些更新。

      但不要相信我的话:测试一下!编写一个可以接收和反序列化 3 或 4 种数据包的小程序,使用阻塞线程和队列来传递对象,然后使用单线程和 IOCP 重新编写它,并在完成例程中进行反序列化和排队。将足够多的数据包通过它以使运行时间达到分钟范围,并测试哪个数据包最快。确保您的测试应用程序中的某些东西(即某个线程)正在消耗队列中的对象,以便您全面了解相关性能。

      当你完成两个测试程序后返回这里,让我们知道哪个效果最好,嗯?哪个最快,哪个你更愿意在未来维护,哪个需要最长时间才能让它工作,等等。

      【讨论】:

      • 无法提供更多细节,但“没有”。
      【解决方案6】:

      如果要支持多个同时连接,则需要使用事件驱动的网络方法。我知道两个好的库:libev(由nodeJS 使用)和libevent。它们非常便携且易于使用。我已经在一个支持数百个并行 TCP/UDP(DNS) 连接的应用程序中成功使用了 libevent。

      我相信在服务器中使用事件驱动的网络 i/o 并不是过早的优化——它应该是默认的设计模式。如果你想做一个快速的原型实现,最好从更高级的语言开始。对于 JavaScript,有 nodeJS,对于 Python,有 Twisted。我个人都可以推荐。

      【讨论】:

      • 事件驱动的 I/O 绝对是一个很棒的工具。 Linux 中的轮询或 Mac OS 或 FreeBSD 中的 kqueue 使得执行大量反应式 I/O 变得非常简单,非常简单。当 Jonathan Lemon 第一次在 FreeBSD 中实现 kqueue 时,其中一个演示程序是一个简单的聊天服务器,它可以接受来自 TCP 套接字的输入并将其发送到所有其他连接的 TCP 套接字上,这是一个超级简单的聊天服务器。它可以在 450 MHz K6-2 机器上支持和服务 40,000 个连接的客户端。
      【解决方案7】:

      NodeJS 怎么样? 支持UDP,扩展性强。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2017-08-17
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2016-04-01
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多