向上移动堆栈,让我们看看System.Net.Sockets。自从.NET Core诞生以来,TechEmpower基准就被用作衡量进展的一种方式。以前我们主要关注“明文”基准,非常低级的一组特定的性能特征,但对于这个版本,我们希望专注于改善两个基准,“JSON序列化”和“财富”(后者涉及数据库访问,尽管它的名字,前者的成本主要是由于网络速度非常小的JSON载荷有关)。我们的工作主要集中在Linux上。当我说“我们的”时,我不仅仅是指那些在.NET团队工作的人;我们通过一个超越核心团队的工作小组进行了富有成效的合作,例如红帽的@tmds和Illyriad Games的@benaadams的伟大想法和贡献。
在Linux上,socket实现是基于epoll的。为了实现对许多服务的巨大需求,我们不能仅仅为每个套接字分配一个线程,如果对套接字上的所有操作都使用阻塞I/O,我们就会这样做。相反,使用非阻塞I/O,当操作系统还没有准备好来满足一个请求(例如当ReadAsync用于套接字但没有数据可供阅读,或使用非同步套接字但是没有可用空间在内核的发送缓冲区),epoll用于通知套接字实现的套接字状态的变化,这样操作可以再次尝试。epoll是一种使用一个线程有效地阻塞任何数量套接字的更改等待的方法,因此实现维护了一个专用的线程,等待更改的所有套接字注册的epoll。该实现维护了多个epoll线程,这些线程的数量通常等于系统中内核数量的一半。当多个套接字都复用到同一个epoll和epoll线程时,实现需要非常小心,不要在响应套接字通知时运行任意的工作;这样做会发生在epoll线程本身,因此epoll线程将无法处理进一步的通知,直到该工作完成。更糟糕的是,如果该工作被阻塞,等待与同一epoll关联的任何套接字上的另一个通知,系统将死锁。因此,处理epoll的线程试图在响应套接字通知时做尽可能少的工作,提取足够的信息将实际处理排队到线程池中。
事实证明,在这些epoll线程和线程池之间发生了一个有趣的反馈循环。来自epoll线程的工作项排队的开销刚好足够支持多个epoll线程,但是多个epoll线程会导致队列发生一些争用,以至于每个额外的线程所增加的开销都超过了它的公平份额。最重要的是,排队的速度只是足够低,线程池将很难保持它的所有线程饱和的情况下会发生少量的工作在一个套接字操作(这是JSON序列化基准的情况);这将反过来导致线程池花费更多的时间来隔离和释放线程,从而使其变慢,从而创建一个反馈循环。长话短说,不理想的排队会导致较慢的处理速度和比实际需要更多的epoll线程。这被纠正与两个PRs, dotnet/runtime#35330和dotnet/runtime#35800。#35330改变了从epoll线程排队模型,而不是排队一个工作项/事件(当epoll醒来通知,可能会有多个通知所有的套接字注册它,和它将提供所有的通知在一批),它将整个批处理队列的一个工作项。处理它的池线程然后使用一个非常类似于并行的模型。For/ForEach已经工作多年,也就是说,排队的工作项可以为自己保留一个项,然后将自己的副本排队以帮助处理剩余的项。这改变了微积分,最合理大小的机器,它实际上成为有利于减少epoll线程而不是更多(并非巧合的是,我们希望有更少的),那么# 35800 epoll线程的数量变化,通常使用最终只是一个(在机器与更大的核心方面,还会有更多)。我们还通过通过DOTNET_SYSTEM_NET_SOCKETS_THREAD_COUNT epoll数可配置环境变量,可以设置为所需的计算以覆盖系统的默认值,如果开发人员想要实验与其他数量和提供反馈结果给定的工作负载。
作为一个实验,从@tmds dotnet/runtime#37974我们还添加了一个实验模式(由DOTNET_SYSTEM_NET_SOCKETS_INLINE_COMPLETIONS环境变量设置为1在Linux上)我们避免排队的工作线程池,而不是仅仅运行所有套接字延续(如工作()等待socket.ReadAsync ();工作()