网络延迟和吞吐量

三个主要问题与网络的最佳使用有关:

  • 网络延迟
  • 网络饱和度
  • 数据包处理的影响

本部分介绍需要使用 RPC 的编程任务,然后设计两种解决方案:一个编写不当,一个编写良好。 然后仔细审查这两种解决方案,并讨论它们对网络性能的影响。

在讨论这两种解决方案之前,接下来的几节将讨论并阐明与网络相关的性能问题。

网络延迟

网络带宽和网络延迟是单独的术语。 高带宽的网络不保证低延迟。 例如,遍历附属链路的网络路径通常具有较高的延迟,即使吞吐量非常高。 网络往返遍历卫星链路时延迟 5 秒或更多的情况并不少见。 这种延迟的含义是:设计为发送请求、等待回复、发送另一个请求、等待另一个回复等方式的应用程序将等待至少 5 秒才能进行每个数据包交换,而不管服务器的速度有多快。 尽管计算机的速度越来越快,但卫星传输和网络媒体都基于光速,光速通常保持不变。 因此,现有附属网络的延迟不太可能改善。

网络饱和度

许多网络中会出现一些饱和情况。 最容易饱和的网络是慢速调制解调器链路,例如标准 56k 模拟调制解调器。 但是,与单个段中许多计算机的以太网链接也可能变得饱和。 对于带宽较低或链路过载的广域网也是如此,例如可以处理有限流量的路由器或交换机。 在这种情况下,如果网络发送的数据包数超过其最弱链路可以处理的数据包数,则会丢弃数据包。 为了避免拥塞,在检测到丢弃的数据包时,Windows TCP 堆栈会缩减,这可能会导致严重的延迟。

数据包处理的影响

当为更高级别的环境(如 RPC、COM 甚至 Windows 套接字)开发程序时,开发人员往往会忘记每个发送或接收的数据包在后台进行多少工作。 当数据包从网络到达时,计算机将处理来自网络卡中断。 然后,延迟过程调用 (DPC) 排队,并且必须通过驱动程序。 如果使用任何形式的安全,则可能必须解密数据包,或验证加密哈希。 还必须在每个状态执行大量有效性检查。 只有这样,数据包才会到达最终目标:服务器代码。 发送许多小数据块会导致每个小数据块的数据包处理开销。 发送一个大块数据往往在整个系统中消耗的 CPU 时间要少得多,尽管与一个大区块相比,许多小区块的执行成本对于服务器应用程序而言可能相同。

示例 1:设计不佳的 RPC 服务器

假设一个必须访问远程文件的应用程序,而手头的任务是设计一个 RPC 接口来操作远程文件。 最简单的解决方案是镜像本地文件的工作室文件例程。 这样做可能会导致一个看似干净且熟悉的界面。 下面是一个缩写的 .idl 文件:

typedef [context_handle] void *remote_file;
... .
interface remote_file
{
    remote_file remote_fopen(file_name);
    void remote_fclose(remote_file ...);
    size_t remote_fread(void *, size_t, size_t, remote_file ...);
    size_t remote_fwrite(const void *, size_t, size_t, remote_file ...);
    size_t remote_fseek(remote_file ..., long, int);
}

这看起来很优雅,但实际上,这是一个久已久的性能灾难食谱。 与流行观点相反,远程过程调用不仅仅是一个本地过程调用,在调用方和被叫方之间有一条线路。

若要查看此方案如何提高性能,请考虑一个 2K 文件,其中从开头读取 20 个字节,然后从末尾读取 20 个字节,并了解其执行方式。 在客户端, (省略许多代码路径以简洁) 进行以下调用:

rfp = remote_fopen("c:\\sample.txt");
remote_read(...);
remote_fseek(...);
remote_read(...);
remote_fclose(rfp);

现在,假设服务器通过卫星链路与客户端分离,往返时间为 5 秒。 每个调用都必须等待响应才能继续,这意味着执行此序列的绝对最小值为 25 秒。 考虑到仅检索 40 个字节,性能非常缓慢。 此应用程序的客户会很愤怒。

现在假设网络已饱和,因为网络路径中某个位置的路由器容量负担过重。 此设计强制路由器处理至少 10 个数据包,如果我们没有针对每个请求的安全 (一个,每个回复) 一个。 这也是不好的。

此设计还强制服务器接收 5 个数据包并发送 5 个数据包。 同样,不是一个很好的实现。

示例 2:设计更好的 RPC 服务器

让我们重新设计示例 1 中讨论的接口,看看是否可以改进它。 请务必注意,使此服务器真正良好需要了解给定文件的使用模式:本示例不采用此类知识。 因此,这是一个设计更好的 RPC 服务器,但不是设计最佳的 RPC 服务器。

此示例中的想法是将尽可能多的远程操作折叠为一个操作。 第一次尝试如下:

typedef [context_handle] void *remote_file;
typedef struct
{
    long position;
    int origin;
} remote_seek_instruction;
... .
interface remote_file
{
    remote_fread(file_name, void *, size_t, size_t, [in, out] remote_file ..., BOOL CloseWhenDone, remote_seek_instruction *...);
    size_t remote_fwrite(file_name, const void *, size_t, size_t, [in, out] remote_file ..., BOOL CloseWhenDone, remote_seek_instruction *...);
}

此示例将所有操作折叠为读取和写入,这允许在同一操作上打开可选,以及可选的关闭和查找。

以缩写形式编写的操作序列如下所示:

remote_read("c:\\sample.txt", ..., &rfp, FALSE, NULL);
remote_read(NULL, ..., &rfp, TRUE, seek_to_20_bytes_before_end);

考虑设计更好的 RPC 服务器时,在进行第二次调用时,服务器会检查 file_name 是否为 NULL,并使用 rfp 中存储的打开文件。 然后,它看到有查找指令,并将文件指针在读取前放置 20 个字节。 完成后,它将识别 CloseWhenDone 标志设置为 TRUE,并关闭文件并关闭 rfp。

在高延迟网络上,此更好的版本需要 10 秒才能完成 () 速度快 2.5 倍,并且只需处理 4 个数据包:两个从服务器接收,两个从服务器发送。 与所有其他操作相比,服务器执行的额外的 if 和 unmarshals 可忽略不计。

如果正确指定了因果排序,甚至可以使接口异步,并且两个调用可以并行发送。 使用因果排序时,调用仍按顺序进行调度,这意味着在高延迟网络上,即使发送和接收的数据包数相同,也只能忍受 5 秒的延迟。

通过创建一个采用结构数组的方法(描述特定文件操作的数组的每个成员),我们可以进一步折叠这一点;散点/收集 I/O 的远程变体。 只要每个操作的结果不需要在客户端上进一步处理,这种方法就可得到回报;换句话说,无论读取的前 20 个字节是什么,应用程序都会在末尾读取 20 个字节。

但是,如果在读取前 20 个字节以确定下一个操作后,必须对前 20 个字节执行某些处理,则将所有内容折叠到一个操作中不起作用 (至少在所有情况下) 都不起作用。 RPC 的优雅在于,应用程序可以在 接口中同时具有这两种方法,并根据需要调用任一方法。

通常,当涉及网络时,最好将尽可能多的调用合并到单个调用中。 如果应用程序有两个独立的活动,请使用异步操作并让它们并行运行。 实质上,保持管道已满。