.NET中测量多线程基准性能
多线程基准性能是用来衡量计算机系统或应用程序在多线程环境下的执行能力和性能的度量指标。它通常用来评估系统在并行处理任务时的效率和性能。测量中通常创建多个线程并在这些线程上执行并发任务,以模拟实际应用程序的并行处理需求。
在此,我们用多个线程来完成一个计数任务,简单地测量系统的多线程基准性能,以下的5种测量代码(代码1,代码4,代码5,代码6,代码7)中,都设置了计数器,每一秒计数器的计数量体现了系统的性能。通过对比这些测量方法,可以直观地理解多线程、如何通过多线程充分利用系统性能,以及运行多线程可能存在的瓶颈。
测量方法
先用一个多线程的共享变量自增例子来做多线程基准性能测量:
//代码1:简单的多线程测量多线程基准性能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
tasks[i] = Task.Run(DoWork);
}
while (true)
{
long t = totalCount;
Thread.Sleep(1000);
Console.WriteLine($"{totalCount - t:N0}");
}
void DoWork()
{
while (true)
{
totalCount++;
}
}
//结果
48,493,031
48,572,321
47,788,843
48,128,734
50,461,679
……
因为在多线程环境中,线程之间的切换会导致一些开销,例如保存和恢复线程上下文的时间。如果上下文切换频繁发生,可能会对性能测试结果产生影响,因此上面的代码根据系统的CPU内核数设定启动测试线程的线程数量,这些线程对一个共享的变量进行自增操作。
有多线程编程经验的人不难看出,上面的代码没有正确地保护共享资源,会出现竞态条件。这可能导致数据不一致,操作顺序不确定,或者无法重现一致的性能结果。我们将用代码展示这种情况。
//代码2:展示出竞态条件的代码
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
tasks[i] = Task.Run(DoWork);
}
void DoWork()
{
while (true)
{
totalCount++;
Console.Write($"{totalCount}"+",");
}
}
//结果
1,9,10,3,12,13,4,14,15,16……270035,269913,270037,270038,270036,270040,269987,270042,270043……
从代码2
的运行结果可以看到,由于被不同线程操作,这些线程同时访问和修改totalCount的值,打印出来的totalCount不是顺序递增的。
可见,代码1
没有线程同步机制,我们不能准确测量多线程基准性能。
C#中线程的同步方式,比如传统的锁机制(如lock语句、Monitor类、Mutex类、Semaphore类等)通常使用互斥机制来保护共享资源,以确保同一时间只有一个线程可以访问资源,避免竞争条件。这些锁机制会在代码块被锁定期间阻塞其他线程的访问,使得同一时间只有一个线程可以执行被锁定的代码。
这里使用lock锁作为线程同步机制,修正上面的代码,对共享的变量进行保护,避免共享变量同时被多个线程修改。
//代码3:使用lock锁
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
object totalCountLock = new object();
Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
tasks[i] = Task.Run(DoWork);
}
void DoWork()
{
while (true)
{
lock (totalCountLock)
{
totalCount++;
Console.Write($"{totalCount}"+",");
}
}
}
//结果
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30……
这时的结果就是顺序输出。
我们用含lock的代码来测量多线程基准性能:
//代码4:运用含lock锁的代码测量多线程基准性能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
object totalCountLock = new object();
Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
tasks[i] = Task.Run(DoWork);
}
while (true)
{
long t = totalCount;
Thread.Sleep(1000);
Console.WriteLine($"{totalCount - t:N0}");
}
void DoWork()
{
while (true)
{
lock (totalCountLock)
{
totalCount++;
}
}
}
//结果
16,593,517
16,694,824
16,514,421
16,517,431
16,652,867
……
保证多线程环境下线程安全性,还有一种方式是使用原子操作Interlocked。与传统的锁机制(如lock语句等)不同,Interlocked类提供了一些特殊的原子操作,如Increment、Decrement、Exchange、CompareExchange等,用于对共享变量进行原子操作。这些原子操作是直接在CPU指令级别上执行的,而不需要使用传统的阻塞和互斥机制。它通过硬件级别的操作,确保对共享变量的操作是原子性的,避免了竞争条件和数据不一致的问题。
它更适合用于特定的原子操作,而不是用作通用的线程同步机制。
//代码5:运用原子操作的代码测量多线程基准性能
long totalCount = 0;
int threadCount = Environment.ProcessorCount;
Task[] tasks = new Task[threadCount];
for (int i = 0; i < threadCount; ++i)
{
tasks[i] = Task.Run(DoWork);
}
while (true)
{
long t = totalCount;
Thread.Sleep(1000);
Console.WriteLine($"{totalCount - t:N0}");
}
void DoWork()
{
while (true)
{
Interlocked.Increment(ref totalCount);
}
}
//结果
37,230,208
43,163,444
43,147,585
43,051,419
42,532,695
……
除了使用互斥锁、原子操作,我们也可以设法对多个线程进行数据隔离。ThreadLocal类提供了线程本地存储功能,用于在多线程环境下的数据隔离。每个线程都会有自己独立的数据副本,被储存在ThreadLocal实例中,每个ThreadLocal可以被对应线程访问到。
//代码6:运用含ThreadLocal的代码测量多线程基准性能
int threadCount = Environment.ProcessorCount;
Task[] tasks = new Task[threadCount];
ThreadLocal<long> count = new ThreadLocal<long>(trackAllValues: true);
for (int i = 0; i < threadCount; ++i)
{
int threadId = i;
tasks[i] = Task.Run(() => DoWork(threadId));
}
while (true)
{
long old = count.Values.Sum();
Thread.Sleep(1000);
Console.WriteLine($"{count.Values.Sum() - old:N0}");
}
void DoWork(int threadId)
{
while (true)
{
count.Value++;
}
}
//结果
177,851,600
280,076,173
296,359,986
296,140,821
295,956,535
……
上面的代码使用了ThreadLocal类,我们也可以自定义一个类,给每个线程创建一个对象作为上下文,代码如下:
//代码7:运用含自定义上下文的代码测量多线程基准性能
int threadCount = Environment.ProcessorCount;
Task[] tasks = new Task[threadCount];
Context[] ctxs = new Context[threadCount];
for (int i = 0; i < threadCount; ++i)
{
int threadId = i;
ctxs[i] = new Context();
tasks[i] = Task.Run(() => DoWork(threadId));
}
while (true)
{
long old = ctxs.Sum(v => v.TotalCount);
Thread.Sleep(1000);
Console.WriteLine($"{ctxs.Sum(v => v.TotalCount) - old:N0}");
}
void DoWork(int threadId)
{
while (true)
{
ctxs[threadId].TotalCount++;
}
}
class Context
{
public long TotalCount = 0;
}
//结果:
1,067,502,570
1,100,966,648
1,145,726,019
1,110,163,963
1,069,322,606
……
系统配置
组件 | 规格 |
---|---|
CPU | 11th Gen Intel(R) Core(TM) i5-11300H |
内存 | 16 GB DDR4 |
操作系统 | Microsoft Windows 10 家庭中文版 |
电源选项 | 已设置为高性能 |
软件 | LINQPad 7.8.5 Beta |
运行时 | .NET 7.0.10 |
测量结果
测量方法 | 1秒计数 | 性能百分比 |
---|---|---|
未做线程同步 | 50,461,679 | 118.6% |
lock锁 | 16,652,867 | 39.2% |
原子操作(Interlocked) | 42,532,695 | 100% |
ThreadLocal | 295,956,535 | 695.8% |
自定义上下文(Context) | 1,069,322,606 | 2514.1% |
结果分析
未作线程同步测量到的结果是不准确的,不能作为依据。
根据程序运行的结果可以看到,使用传统的lock锁机制,效率不高。使用原子操作Interlocked
,效率比传统锁要高近2倍。
而实现了线程间隔离的2种方法,效率都比前面的方法要高。使用自定义上下文的程序效率是最高的。
线程间隔离的两种代码,它们主要区别在于线程安全性的实现方式。代码6使用ThreadLocal
类来实现,而代码7使用了自定义的上下文,用一个数组来为每个线程提供一个唯一的上下文。代码6
使用的是线程本地存储(Thread Local Storage,TLS)来实现其功能。它是一种全局变量,可以被正在运行的所有线程访问,但每个线程所看到的值都是私有的。虽然这个特性使ThreadLocal
在多线程编程中变得非常有用,但为了实现这个特性,它在内部实现了一套复杂的机制,比如它会创建一个弱引用的哈希表来存储每个线程的数据。这个内部实现细节增加了相应的计算和访问开销。
对于代码7,它创建了一个名为Context
的类数组,每个线程都有其自己的Context
对象,并在执行过程中修改这个对象。由于每个线程自身管理其Context
对象,不存在任何线程间冲突,这就减少了许多额外的开销。
因此,虽然代码6
和代码7
都实现了线程数据隔离,但代码7
避开了ThreadLocal
的额外开销,因此在性能上表现得更好。
结论
如果能实现线程间的隔离,可以大幅提高多线程代码效率,测量出系统的最大性能值。