IOCP线程 - 澄清?

17

阅读了这篇文章后,它声明:

设备完成任务(IO操作)后,通过中断通知CPU。

... ... ...

然而,“完成”状态仅存在于操作系统级别;进程具有自己的内存空间,必须得到通知。

... ... ...

由于库/BCL正在使用标准的P/Invoke重叠I/O系统,因此它已经将句柄注册到I/O完成端口(IOCP),该端口是线程池的一部分。

... ... ...

因此,借用一个I/O线程池线程短暂地执行APC,这会通知任务已完成。

我对粗体部分很感兴趣:

如果我理解正确,那么在完成IO操作后,它必须通知执行IO操作的实际进程。

问题#1:

这是否意味着它为每个完成的IO操作抓取新的线程池线程?还是专门有一定数量的线程用于此?

问题#2:

看着:

for (int i=0;i<1000;i++)
    {
      PingAsync_NOT_AWAITED(i); //notice not awaited !
    }

这是否意味着当所有线程都完成时,我将同时拥有1000个IOCP线程池线程(类似于)运行?


Royi,你可能想要在这里检查一下我的小实验(https://dev59.com/Q33aa4cB1Zd3GeqPg7VM#22475242)。 - noseratio - open to work
1
@Noseratio 谢谢!我一定会看看的。 - Royi Namir
您可能还想阅读这篇文章,以了解它在操作系统层面上的工作原理:I/O完成端口 - noseratio - open to work
4个回答

17
这是否意味着每次完成 I/O 操作时都会为其获取一个新的线程池线程?或者是专门为此分配的线程数?实际上,为每个 I/O 请求创建一个新线程将非常低效,甚至会达到适得其反的效果。相反,运行时从一小组线程开始(确切的数量取决于您的环境),并根据需要添加和删除工作线程(确切的算法同样因您的环境而异)。.NET 的每个主要版本都看到了这种实现的变化,但基本思想保持不变:运行时尽力只创建和维护尽可能少的线程,以有效地处理所有 I/O。在我的系统(Windows 8.1,.NET 4.5.2)中,全新的控制台应用程序在进入Main时仅具有 3 个进程线程,并且在实际请求工作之前,此数字不会增加。

那么当全部完成时,这是否意味着我将同时拥有1000个IOCP线程池线程? 并不是这样的。当您发出I/O请求时,线程将等待完成端口以获取结果,并调用注册的任何回调来处理结果(无论是通过BeginXXX方法还是作为任务的连续体)。如果您使用任务并且不等待它,该任务仅在那里结束,线程将被返回到线程池中。

如果您确实等待它会怎样呢? 1000个 I/O 请求的结果不会真正同时到达,因为中断并不会同时到达,但是假设间隔时间远小于我们处理它们所需的时间。在这种情况下,线程池将保持旋转以处理结果,直到达到最大限制,并且任何进一步请求都将排队在完成端口上。根据您的配置方式,这些线程可能需要一些时间才能启动。

考虑以下(故意糟糕的)示例程序:

static void Main(string[] args) {
    printThreadCounts();
    var buffer = new byte[1024];
    const int requestCount = 30;
    int pendingRequestCount = requestCount;
    for (int i = 0; i != requestCount; ++i) {
        var stream = new FileStream(
            @"C:\Windows\win.ini",
            FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 
            buffer.Length, FileOptions.Asynchronous
        );
        stream.BeginRead(
            buffer, 0, buffer.Length,
            delegate {
                Interlocked.Decrement(ref pendingRequestCount);
                Thread.Sleep(Timeout.Infinite);
            }, null
        );
    }
    do {
        printThreadCounts();
        Thread.Sleep(1000);
    } while (Thread.VolatileRead(ref pendingRequestCount) != 0);
    Console.WriteLine(new String('=', 40));
    printThreadCounts();
}

private static void printThreadCounts() {
    int completionPortThreads, maxCompletionPortThreads;
    int workerThreads, maxWorkerThreads;
    ThreadPool.GetMaxThreads(out maxWorkerThreads, out maxCompletionPortThreads);
    ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
    Console.WriteLine(
        "Worker threads: {0}, Completion port threads: {1}, Total threads: {2}", 
        maxWorkerThreads - workerThreads, 
        maxCompletionPortThreads - completionPortThreads, 
        Process.GetCurrentProcess().Threads.Count
    );
}

在我的系统上(拥有 8 个逻辑处理器),输出如下(在您的系统上可能会有所不同):

Worker threads: 0, Completion port threads: 0, Total threads: 3
Worker threads: 0, Completion port threads: 8, Total threads: 12
Worker threads: 0, Completion port threads: 9, Total threads: 13
Worker threads: 0, Completion port threads: 11, Total threads: 15
Worker threads: 0, Completion port threads: 13, Total threads: 17
Worker threads: 0, Completion port threads: 15, Total threads: 19
Worker threads: 0, Completion port threads: 17, Total threads: 21
Worker threads: 0, Completion port threads: 19, Total threads: 23
Worker threads: 0, Completion port threads: 21, Total threads: 25
Worker threads: 0, Completion port threads: 23, Total threads: 27
Worker threads: 0, Completion port threads: 25, Total threads: 29
Worker threads: 0, Completion port threads: 27, Total threads: 31
Worker threads: 0, Completion port threads: 29, Total threads: 33
========================================
Worker threads: 0, Completion port threads: 30, Total threads: 34
当我们发出30个异步请求时,线程池会迅速提供8个线程来处理结果,但在此之后,它只会以每秒约2个的悠闲速度启动新线程。这表明,如果您想正确利用系统资源,最好确保您的I/O处理快速完成。实际上,让我们将代理更改为以下内容,它表示对请求的“适当”处理:
stream.BeginRead(
    buffer, 0, buffer.Length,
    ar => {
        stream.EndRead(ar);
        Interlocked.Decrement(ref pendingRequestCount);
    }, null
);

结果:

Worker threads: 0, Completion port threads: 0, Total threads: 3
Worker threads: 0, Completion port threads: 1, Total threads: 11
========================================
Worker threads: 0, Completion port threads: 0, Total threads: 11

请注意,您的系统和不同的运行可能会导致结果有所不同。在这里,我们仅仅瞥见完成端口线程的工作,而我们发出的30个请求则在不启动新线程的情况下得到了完成。您应该会发现,您可以将"30"更改为"100"甚至"100000":我们的循环无法启动比请求更快。然而,请注意,由于"I/O"一直在重复读取相同的字节,并且将从操作系统缓存而不是从磁盘中读取进行服务,因此结果对我们有利。当然,这并不意味着它展示了实际吞吐量,只是说明了开销的差异。

如果想要使用工作者线程而不是完成端口线程来重复这些结果,只需将FileOptions.Asynchronous更改为FileOptions.None即可。这将使文件访问同步,异步操作将在工作者线程上完成,而不使用完成端口:

Worker threads: 0, Completion port threads: 0, Total threads: 3
Worker threads: 8, Completion port threads: 0, Total threads: 15
Worker threads: 9, Completion port threads: 0, Total threads: 16
Worker threads: 10, Completion port threads: 0, Total threads: 17
Worker threads: 11, Completion port threads: 0, Total threads: 18
Worker threads: 12, Completion port threads: 0, Total threads: 19
Worker threads: 13, Completion port threads: 0, Total threads: 20
Worker threads: 14, Completion port threads: 0, Total threads: 21
Worker threads: 15, Completion port threads: 0, Total threads: 22
Worker threads: 16, Completion port threads: 0, Total threads: 23
Worker threads: 17, Completion port threads: 0, Total threads: 24
Worker threads: 18, Completion port threads: 0, Total threads: 25
Worker threads: 19, Completion port threads: 0, Total threads: 26
Worker threads: 20, Completion port threads: 0, Total threads: 27
Worker threads: 21, Completion port threads: 0, Total threads: 28
Worker threads: 22, Completion port threads: 0, Total threads: 29
Worker threads: 23, Completion port threads: 0, Total threads: 30
Worker threads: 24, Completion port threads: 0, Total threads: 31
Worker threads: 25, Completion port threads: 0, Total threads: 32
Worker threads: 26, Completion port threads: 0, Total threads: 33
Worker threads: 27, Completion port threads: 0, Total threads: 34
Worker threads: 28, Completion port threads: 0, Total threads: 35
Worker threads: 29, Completion port threads: 0, Total threads: 36
========================================
Worker threads: 30, Completion port threads: 0, Total threads: 37

线程池每秒会启动一个工作线程,而不是两个完成端口线程。显然这些数字取决于具体实现,并且可能在新版本中发生变化。

最后,让我们演示如何使用 ThreadPool.SetMinThreads 来确保有足够的线程来完成请求。如果回到 FileOptions.Asynchronous 并将 ThreadPool.SetMinThreads(50, 50) 添加到我们玩具程序的 Main 中,结果如下:

Worker threads: 0, Completion port threads: 0, Total threads: 3
Worker threads: 0, Completion port threads: 31, Total threads: 35
========================================
Worker threads: 0, Completion port threads: 30, Total threads: 35

现在,线程池不再每两秒钟耐心地添加一个线程,而是一直创建新的线程直到达到最大数量(这种情况下没有达到最大值,所以最终数量保持在30个)。当然,这30个线程中的所有线程都被卡在无限等待状态下--但如果这是一个真实的系统,那么这些30个线程现在可能正在做有用的工作,虽然可能效率不高。不过,我不会尝试使用100000个请求。


15

这个问题比较宽泛,所以我只谈一下主要内容:

IOCP线程池是独立的线程池,可以这么说——这就是I/O线程设置。因此,它们不会与用户线程池中的线程发生冲突(例如,在普通await操作或ThreadPool.QueueWorkerItem中使用的线程)。

就像普通线程池一样,它只会慢慢地分配新的线程。因此,即使有一堆异步响应同时发生的高峰期,你也不会拥有1000个I/O线程。

在一个正确的异步应用程序中,你不会有超过核心数的线程,大体上。这与工作线程相同。这是因为你要么在做重要的CPU工作,那么你应该将其发布到普通工作线程上,要么在做I/O工作,那么你应该将其作为异步操作进行。

这个想法是你在I/O回调中花很少的时间——你不会阻塞,也不会做很多的CPU工作。如果你违反了这个规则(比如,在回调中添加了Thread.Sleep(10000)),然后是的,.NET会随着时间的推移创建很多的I/O线程——但这只是不正确的用法。

现在,I/O线程和普通CPU线程有什么不同?它们几乎相同,只是等待不同的信号——两者(简化提示)都是在方法上循环while,当其他应用程序部分(或操作系统)排队新工作项时,控制权就会移交给它们。主要区别是I/O线程使用IOCP队列(由操作系统管理),而普通工作线程有自己的队列,完全由.NET管理,并可被应用程序员访问。

顺带提一下,不要忘记你的请求可能已经同步完成了。也许你正在通过while循环每次读取512个字节从TCP流中读取数据。如果套接字缓冲区中有足够的数据,多个ReadAsync调用可以立即返回而不进行任何线程切换。这通常不是问题,因为I/O往往是典型应用程序中最耗时的操作,所以不必等待I/O通常是可以的。但是,依赖于某些部分异步发生(尽管没有保证)的糟糕代码可能会轻松破坏你的应用程序。


1
有一种分离,但两种类型的线程都在同一个“线程池”中。您可以使用相同的方法设置所需的数量:ThreadPoo.SetMaxThreads(int workerThreads, int completionPortThreads)。 - i3arnon
@i3arnon ThreadPool并不是一个池,它只是一个静态类中的一堆方法。有单独的工作队列和线程池,其中一些由操作系统管理,一些由CLR本地代码管理,一些由托管CLR代码管理...这有点复杂。你通过ThreadPool类与所有这些交互,但它们甚至没有相同的接口(例如BindHandleQueueUserWorkItem)。现在CLR代码已经公开,尝试挖掘一下,这是非常有趣和有关多线程和异步代码的深入见解。 - Luaan
嗯,我猜这取决于你如何定义线程池。我会遵循MSDN的解释:“线程池根据需要提供新的工作者线程或I/O完成线程,直到每个类别达到最小值为止。当达到最小值时,线程池可以在该类别中创建其他线程,或者等待一些任务完成。” - i3arnon
@i3arnon单独表示“该类别中的其他线程”意味着有不同的池 :) 但这实际上只涉及命名。只要您理解存在两个独立的线程池(工作线程 vs. I/O),它只是命名上的混淆。 - Luaan

5
这是否意味着我会同时拥有1000个IOCP线程池线程在此运行(某种程度上)?不,完全不是。就像ThreadPool中可用的工作线程一样,我们也有“完成端口线程”。这些线程专门用于异步I/O。不会预先创建线程。它们将按需创建,就像工作线程一样。当线程池决定时,它们最终将被销毁。通过“borrowed briefly”作者指的是使用“完成端口线程”(线程池的线程)中的任意线程来通知进程的IO完成。它不会执行任何长时间操作,而只是完成IO通知。

如果我从一个网站下载了一个HTML文件,并且下载已经完成,但是应用程序还没有读取它(但已经通知了),那么这些数据存储在哪里? - Royi Namir
4
它在某个缓冲区中。有许多层缓冲,所以很难确定确切位置。然而,当您收到通知时,它已经必须在您的缓冲区中 - 当然,如果您使用像HttpClient这样的东西,则是它的缓冲区,而如果您直接使用例如TcpClient,则是您在执行ReceiveAsync时给它的byte[]缓冲器。当然,这就是您想要使用最高可用抽象的原因之一 - 网络(和任何异步操作)很难,让聪明的家伙处理最困难的部分:D - Luaan

2
正如我们之前所讨论的,IOCP和工作线程在线程池内部有独立的资源。
无论您是否await IO操作,都会发生向IOCP或重叠IO的注册。await是一种更高级别的机制,与这些IOCP的注册无关。
通过简单的测试,您可以看到即使没有await发生,应用程序仍然在使用IOCP:
private static void Main(string[] args)
{
    Task.Run(() =>
    {
        int count = 0;
        while (count < 30)
        {
            int _;
            int iocpThreads;
            ThreadPool.GetAvailableThreads(out _, out iocpThreads);
            Console.WriteLine("Current number of IOCP threads availiable: {0}", iocpThreads);
            count++;
            Thread.Sleep(10);
        }
    });

    for (int i = 0; i < 30; i++)
    {
        GetUrl(@"http://www.ynet.co.il");
    }

    Console.ReadKey();
}

private static async Task<string> GetUrl(string url)
{
    var httpClient = new HttpClient();
    var response = await httpClient.GetAsync(url);
    return await response.Content.ReadAsStringAsync();
}

根据每个请求所需的时间不同,您在进行请求时会看到 IOCP 缩小。您尝试进行的并发请求越多,可用线程就越少。

1
我会更改连接限制,因为在这里您只能使用约4个连接... System.Net.ServicePointManager.DefaultConnectionLimit = 1000(依我之见) - Royi Namir
其实这个数字是不重要的,关键是要看那些IOCP是否真正被使用,即使您没有等待任何请求。 - Yuval Itzchakov
哦,只是想指出来,以便其他人知道为什么会有更准确的结果 :-)。 - Royi Namir
此外,我认为我们正在谈论不同的线程。在你的代码中,我们看到 out iocpThreads 值发生了变化,但这是因为 await 完成后,继续运行在不同的线程上。因此导致数字改变,此外,这里的其他人说它是一个不同的线程池。(那么哪个陈述是真实的呢?),另外,我所说的线程是 "_borrowed briefly_来通知任务完成"的线程...而不是服务于continuation 的线程。 - Royi Namir
@RoyiNami 是的,基本上是这样。当然,如果你做得对,它永远不会超过计算机可以同时处理的数量。现在使用 await 和类似的结构比以往任何时候都更容易了。如果你没有从头到尾将整个 I/O 设为异步,你仍然可能会发现自己有大量的 I/O 线程,例如在传统的 WCF 服务中(I/O 线程是具有请求上下文的线程)。但是,在客户端,真的没有任何借口。 - Luaan
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接