我有一个遗留项目,有很多 IoC 和HttpClient
调用。为了提高性能,我正在尝试使用 TPL 来并行化工作。但表现变得更糟。
总之,我们尝试并行化封装异步方法的同步方法。 重构后,性能更好,但我不理解这种行为。
我制作了这个最小的代码示例来重现 .NET 4.7 控制台项目中的行为:
class Program
{
static void Main(string[] args)
{
var tasks = new List<Task>();
for (int i = 0; i < 15; i++)
{
var n = i;
tasks.Add(Task.Run(() => WorkSync(n)));
Thread.Sleep(TimeSpan.FromMilliseconds(1));
}
Task.WaitAll(tasks.ToArray());
}
private static void WorkSync(int i)
{
Debug.WriteLine($"{i:000}t{DateTime.Now:HH:mm:ss.fff}tStartA");
WorkAsync(i).GetAwaiter().GetResult();
Debug.WriteLine($"{i:000}t{DateTime.Now:HH:mm:ss.fff}tFinishA");
}
private static async Task WorkAsync(int i)
{
Debug.WriteLine($"{i:000}t{DateTime.Now:HH:mm:ss.fff}tStartB");
await Task.Run(() => Work(i));
Debug.WriteLine($"{i:000}t{DateTime.Now:HH:mm:ss.fff}tFinishB");
}
private static void Work(int i)
{
Debug.WriteLine($"{i:000}t{DateTime.Now:HH:mm:ss.fff}tDo Something");
}
}
结果:
004 11:30:10.629 StartA
000 11:30:10.627 StartA
002 11:30:10.627 StartA
001 11:30:10.627 StartA
003 11:30:10.627 StartA
005 11:30:10.628 StartA
006 11:30:10.628 StartA
007 11:30:10.628 StartA
008 11:30:10.633 StartA
002 11:30:10.692 StartB
001 11:30:10.692 StartB
000 11:30:10.692 StartB
003 11:30:10.692 StartB
005 11:30:10.692 StartB
004 11:30:10.692 StartB
006 11:30:10.695 StartB
007 11:30:10.699 StartB
008 11:30:10.703 StartB
009 11:30:11.632 StartA
009 11:30:11.633 StartB
010 11:30:12.616 StartA
010 11:30:12.617 StartB
011 11:30:13.612 StartA
011 11:30:13.613 StartB
012 11:30:14.612 StartA
012 11:30:14.613 StartB
013 11:30:15.612 StartA
013 11:30:15.613 StartB
014 11:30:16.611 StartA
014 11:30:16.612 StartB
002 11:30:17.612 Do Something
002 11:30:17.614 FinishB
002 11:30:17.615 FinishA
001 11:30:17.615 Do Something
001 11:30:17.657 FinishB
006 11:30:17.658 Do Something
005 11:30:17.636 Do Something
006 11:30:17.680 FinishB
005 11:30:17.701 FinishB
007 11:30:17.723 Do Something
001 11:30:17.658 FinishA
005 11:30:17.744 FinishA
004 11:30:17.744 Do Something
007 11:30:17.765 FinishB
004 11:30:17.808 FinishB
006 11:30:17.723 FinishA
007 11:30:17.830 FinishA
003 11:30:17.894 Do Something
013 11:30:17.786 Do Something
003 11:30:17.895 FinishB
013 11:30:17.917 FinishB
012 11:30:17.919 Do Something
008 11:30:17.830 Do Something
014 11:30:17.788 Do Something
004 11:30:17.851 FinishA
009 11:30:17.851 Do Something
013 11:30:17.922 FinishA
000 11:30:17.872 Do Something
003 11:30:17.918 FinishA
012 11:30:17.927 FinishB
010 11:30:17.922 Do Something
008 11:30:17.931 FinishB
014 11:30:17.933 FinishB
011 11:30:17.955 Do Something
008 11:30:18.046 FinishA
009 11:30:17.958 FinishB
009 11:30:18.111 FinishA
014 11:30:18.068 FinishA
000 11:30:17.980 FinishB
000 11:30:18.114 FinishA
010 11:30:18.024 FinishB
011 11:30:18.089 FinishB
012 11:30:18.003 FinishA
011 11:30:18.138 FinishA
010 11:30:18.116 FinishA
只有在 Main 中启动所有任务后,才会执行Work
方法。 我已经使用调试器检查了这一点,阻止的不是调试显示。
1(我不明白这个调度。 你能解释一下为什么吗?
2(前 10 个任务开始得非常快,但最后 5 个任务开始得很慢。 你能解释一下为什么吗?
我不明白这个调度。你能解释一下为什么吗?
你有一个紧密的循环来启动大量任务。它们都启动了,但每个线程都启动了另一个线程。直到启动线程调用Debug.WriteLine($"{i:000}tFinishB");
之后才会调度该线程。
Work()
线程被阻塞的一个原因是Debug.WriteLine()
获取了一个锁 - 因此,如果其他线程当前正在写入调试,则Work()
线程将阻塞。这样做的寓意是Debug.WriteLine()
可以改变多线程的行为,因为它使用锁。
非常快,但最后 5 个任务开始得很慢。你能解释一下为什么吗?
但是,发生这种情况还有另一个更有影响力的原因:线程池的"最小线程限制"。
线程池保持准备等待运行的最小线程数。您可以通过以下代码查看该值:
ThreadPool.GetMinThreads(out int workers, out int ports);
Console.WriteLine(workers); // Prints 8 on my system.
现在这里要知道的重要一点是,如果需要的线程数超过最小数量,则只会在延迟几百毫秒后创建新线程(不确定确切多长时间,但似乎大约一秒(。
因此,除了Debug.WriteLine()
实现中的锁造成的阻塞之外,还发生了以下情况:
- 启动的任务负载超过最小线程池大小,因此在启动前几个任务后,新任务之间会引入延迟。
- 由于这种延迟,当涉及到正在启动的
Work()
任务时,它被延迟了。这会导致它比其他方式晚得多。
您可以通过在测试代码开始时增加最小线程池线程数并观察输出的差异来证明这种情况正在发生。
若要尝试此操作,请在启动任何任务之前添加以下代码行:
ThreadPool.SetMinThreads(100, 100);
当我尝试这样做时,所有任务的启动速度都更快,并且一些"做某事"消息在所有其他任务启动之前出现(而以前这些消息仅在所有其他任务启动后出现,正如您所注意到的(。
注意Microsoft 不建议更改最小线程数:
可以使用 ThreadPool.SetMinThreads 方法来增加空闲线程的最小数目。但是,不必要地增加这些值可能会导致性能问题。如果同时启动的任务太多,则所有任务可能看起来都很慢。在大多数情况下,线程池使用自己的分配线程算法会表现得更好。