三个主要问题与网络的最佳使用有关:
- 网络延迟
- 网络饱和度
- 数据包处理影响
本部分介绍需要使用 RPC 的编程任务,然后设计两种解决方案:一个编写不当,一个编写得很好。 然后仔细审查这两种解决方案,并讨论它们对网络性能的影响。
在讨论这两个解决方案之前,接下来的几个部分将讨论并阐明与网络相关的性能问题。
网络延迟
网络带宽和网络延迟是单独的术语。 高带宽的网络不能保证低延迟。 例如,遍历附属链路的网络路径通常具有较高的延迟,即使吞吐量非常高。 网络往返遍历卫星链路有五或更多秒的延迟并不罕见。 这种延迟的含义是:一个应用程序旨在发送请求、等待回复、发送另一个请求、等待另一个答复等,将等待至少五秒以交换每个数据包,而不管服务器的速度如何。 尽管计算机速度越来越快,卫星传输和网络媒体仍以光速为基础,这通常保持不变。 因此,现有卫星网络的延迟改进不太可能发生。
网络饱和度
许多网络中会出现一些饱和。 最容易饱和的网络是慢速调制解调器链路,例如标准 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 个数据包:两个从服务器接收,两个从服务器发送。 如果服务器执行 和取消划界,则额外的 相比,与其他所有作相比是微不足道的。
如果正确指定因果顺序,甚至可以异步执行接口,并且可以并行发送两个调用。 使用因果排序时,调用仍按顺序调度,这意味着在高延迟网络上,即使发送和接收的数据包数相同,也只承受了 5 秒的延迟。
通过创建一个采用结构数组的方法(描述特定文件作的数组的每个成员),我们可以进一步折叠这一点:散点/收集 I/O 的远程变体。 只要每个作的结果不需要对客户端进行进一步处理,此方法就会得到回报;换句话说,无论前 20 个字节读取是什么,应用程序都会在末尾读取 20 个字节。
但是,如果在读取后必须对前 20 个字节执行某些处理以确定下一个作,则将所有内容折叠成一个作不起作用(至少在所有情况下都不起作用)。 RPC 的优雅在于,应用程序可以在接口中具有这两种方法,并根据需要调用任一方法。
一般情况下,当网络涉及时,最好将尽可能多的呼叫合并到单个调用中。 如果应用程序有两个独立活动,请使用异步作并让他们并行运行。 实质上,使管道保持完整。