上个月的专栏文章通过在 Windows 和 Linux 上使用最佳原语,研究了纯上下文切换次数。根据那些结果,Windows 中的上下文切换时间只有 Linux 的一半。我具此得出结论:Windows 中选择线程来运行的算法得到了很好的优化。
本月的专栏文章将研究两个问题。首先,我们研究通过向调度区域插入多个额外的锁来为调度程序算法提供多种选择。这里,我们仍然只研究切换算法而不让处理器完成任何实际工作。最后,在第二个问题中,我们研究用线程完成实际工作。我的工作负载由对一个分形点的 CPU 密集型计算组成。我让每个线程计算一个分形点,然后观察,在一段给定时间之后每个线程能完成多少次迭代。这么多线程都会获得等量的时间吗?
还记得吗?上个月的专栏文章显示了使用适当的处理同步原语可以对执行上下文切换所需的时间造成巨大差异。在每种平台上选择最佳原语来测量上下文切换时间能够得到相当公平的比较结果。尽管上个月的程序不完成任何工作,但它确实演示了两个系统中的上下文切换速度。
第一个程序是 csfast5a.cpp(请参阅 参考资料来下载它),它对上一篇专栏文章中的 csfast5.cpp 进行了少量修改。本月,将让 csfast5a 来启动任意数量的线程,其中每个线程都会锁定和解锁(使用进程同步原语)给定的次数,并在完成后记录其使用的时间。我将研究如果可以在任一时间执行多个线程,是否可以用更少时间完成更多工作。例如,32 个线程,每个都要锁定和解锁 50000 次,总共就需要 1600000 次调用。这个测试仍然不完成任何有用的工作;第二个程序将完成实际操作。
要测量每个线程的执行时间,必须增强我们的计时原语以使之成为是线程安全的。基本的更改要求是为
tstart() 和
tend() 找到存储计时信息的地方。先前,它们仅被存储在单一全局内存位置中。显然,在可能执行数百个线程的情形中,这样是不会奏效的。您可以更改计时例程
tstart() 和
tend() ,使它们成为线程安全的,如下所示:
#ifdef _WIN32
#define INT64 __int64
static INT64 freq; // GLOBAL
static int tfirst = 1;
void tstart(INT64 *t)
{
if(tfirst) {
QueryPerformanceFrequency(&freq);
tfirst = 0;
}
QueryPerformanceCounter(t);
}
void tend(INT64 *t)
{
QueryPerformanceCounter(t);
}
double tval(INT64 *t1, INT64 *t2)
{
return ((double)t2->QuadPart -
(double)t1->QuadPart)/((double)freq.QuadPart);
}
#else
void tstart(struct timeval *t)
{
gettimeofday(t, NULL);
}
void tend(struct timeval *t)
{
gettimeofday(t,NULL);
}
#endif
|
新的计时例程对每个线程进行计时,其中存储计时信息的位置是分配给每个线程的内存的一部分。
这个测试要求 32 个线程各执行 50000 次锁定/解锁操作。每个线程将进行 50000 次锁定/解锁调用,并在其线程内存中保存其启动和完成时间。当一切完成后,主线程检查所有信息并计算所有 32 个线程的平均时间 — 任一线程所花费的最长时间和最短时间。然后,打印结果并终止程序。
第一个案例是 32 个线程和 33 个锁。 最初,所有线程都被锁定,并等待释放下一个线程的锁。为了使操作进行,您可以添加一个额外的锁,以便列表中最后一个线程实际上可以获得额外的锁。当它成功地获得一个锁,它释放自己的锁,唤醒前一个线程,这个线程也做同样的事。因为区域中只有一个额外锁,所以每次只能运行一个线程。大多数获得锁的尝试都会引起操作系统中的阻塞。
使用一个额外锁时,我们的测量结果显示 Windows 2000 中每个线程完成 50000 次锁定/解锁调用平均花费 8.4 秒。Linux 中的平均时间为 12.3 秒。Windows 和 Linux 的最长时间和最短时间平均都在 10 毫秒以内。这意味着给定数量的工作在线程之间是均匀分布的。还记得吗?这里的工作很琐细,并且常常会导致操作系统发生阻塞。
在提供图形结果之前,您应该确切地知道锁定尝试每隔多久才会实际成功。我们打算对它们进行计数。既然锁定是一种系统调用原语,其行为不会因阻塞而异,那么我们怎么能做到这一点呢?还记得吗?在上一篇专栏文章中您了解了在 650 MHz TP600X 上,当使用优化原语时,上下文切换可以在任何情况下在 2 至 10 微秒中完成。显然,这种特定的计时将因处理器而异。我现在仍在同一机器上工作,因此两种计时结果是相关的。
我使用这种试探法:如果对
lock() 的一次调用花费的时间超过 10 微秒,则锁定该程序。这种不精确的试探法取决于处理器的速度。但是,我的 TP600X 是我在上一篇专栏文章中使用的同一机器,因此 10 微秒现在仍是合适的(完成该任务的唯一可靠方法是检测操作系统)。
因此,csfast5a.cpp 也使用这种试探法对每个线程实际阻塞的次数进行计数。计数的代码看起来如下:
tstart(&ts);
Lock(k1);
tend(&te);
tv = tval(&ts,&te);
if(tv < 1e-5) { // 10 microseconds
noswitch++;
// no context switch
}
else {
switched++;
// must have context switched.
}
|
请记住,这个特定的计算只是给出了大致的想法。不检测内核,就难以进行更精确的测量。
图 1. 完成时间 vs. 额外锁(32 个线程)
我对 32 个线程和 1 至(8192-32)个额外锁运行了测试。我绘制了 32 至 64 之间所有的点,然后只是绘制了大于 64 的 2 的 n 次幂的点。我在 Windows 2000 Advanced Server(无 Service Pack)和 Red Hat 7.3 Linux 2.4.18-2 上运行了该测试。WIN2KAS-64 和 RH73-64 分别是在 Windows 和 Linux 上测试的 32 至 64 之间的点集(本工作中不存在 64 位处理器)。图 1 显示了当有更多锁时,执行 50000 次锁定/解锁操作的时间会减少。图 2 演示了略有不同的观点,其中发生阻塞的可能性随着锁数的增加而增长。因为我程序中的线程连续地请求锁定,所以完成时间方面的改进比率与锁数的增加相比是缓慢的。 但是,该图清晰地显示了增加额外锁带来了有用的结果。(注:对于两幅图而言,csfast5a.cpp 都是用来演示向池添加资源的。Linux 中使用了互斥锁 (Mutex),Windows 中使用临界段 (Critical Section)。)
图 2. 不阻塞的可能性(32 个线程)
第二个程序 fract5.cpp(请参阅 参考资料以下载它)启动了给定数量的线程并执行“伪”实际的工作。这个驻留在内存的工作计算分形点。每个线程可以在给定的已分配的内存块上可选地执行内存复制操作。这个测试不研究向系统增加内存负载所造成的影响。我们试图找到在出现资源缺乏之前必须运行多少线程。当线程无法得到计算机的执行上下文时,资源缺乏就发生了。
我不会寻找完整的资源缺乏,而是更愿意清晰地演示线程资源缺乏。为了轻松地演示这一点,fract5.cpp 程序需要用上一篇专栏文章中演示的自动命令行生成选项。以下是用于我的测试的命令串:
fract5-1 -s -t 1,1024,2,1 4
表 1 解释了这条“神秘”的命令。我将程序命名为 fract5-1 是因为它在 Windows 上是使用 -O1 优化选项编译的。本月,Windows 出现了反常的行为,其中 -O2 编译器优化选项比 -O1 慢。在 Linux 上,-O2 产生了最佳效果(别让程序名上的“-1”干扰了您)。
表 1. 命令字符串说明
| 选项 | 说明 |
| -s | 打印摘要 |
| C-t 1,1024,2,1 | 运行 1、2、3、4、6、8、12、16、24、32、48、64、96、128、192、256、384、512、768、1024 个线程 |
| 4 | 每次运行将持续 4 秒 |
表 2 说明了使用下列命令字符串的 fract5.cpp 程序的输出:
fract5-2 -s -t 1,192,2,1 4
输出是:
4,1,0,0,23.4412,4.0199,2,0
其格式是:
seconds,threads,unscheduled-threads,tmem,iter/usec,total_time,yields,yields2
表 2. fract5.cpp 输出说明
| 输出 | 参数 | 说明 |
| 4 | seconds | 线程执行的秒数 |
| 1 | threads | 运行中的线程数 |
| 0 | unscheduled-threads | 完成 0 个分形循环的线程数 |
| 0 | tmem |
memset() 操作中使用的内存量
|
| 23.4412 | iter/usec | 每微秒进行的总计迭代数 |
| 4.0199 | total_time | 运行花费的时间。取决于 Windows 上的方式(后台处理或应用程序性能调优),程序可以花费大量时间来将执行线程分配给那些试图变得“礼貌”的线程。 |
| 2 | yields | fract5.cpp 中有两个地方,每个线程在其中等待来自主线程的信号进行启动。线程将到达集合点并让出处理器。如果必须多次做这一点,则线程运行得比主循环更频繁。Yields 是在启动主循环之前对在第一个位置处的让出数的计数。 |
| 0 | yields2 | 第二个位置是在主循环终止之后。Yields2 是当主线程可能已经运行时这个线程的运行次数的计数。 |
表 3 显示了在 Linux 和 Windows 2000 Advanced Server 上运行同一测试的结果。该表表明对于 4 秒钟运行,在 Windows 上运行大约 96 个线程时,线程资源缺乏就开始了,在 Linux 上则为 128 个线程。请再次注意,对于现有的 Red Hat 7.3,Linux 对于每个进程仅支持 256 个线程。
还请注意,Windows 已经经过改进,在迭代/微秒比率方面略胜一筹。我们应该料到这一点。先前小于 5% 的差异在统计意义上应该被认为是无关紧要的。尽管我确实重新运行先前的程序以向自己保证任何事情都没有更改,但内循环中的代码不同了,从而允许不同编译器使用更多优化选项。
表 3. 在 Linux 和 Windows 2000 Advanced Server 上运行 fract5.cpp 的结果
| Red Hat 7.3 | Windows 2000 Advanced Server | ||||||||||
| seconds | threads | unscheduled-threads | iter/usec | total_time | yields | yields2 | unscheduled-threads | iter/usec | total_time | yields | yields2 |
| 4 | 1 | 0 | 23.441 | 4.020 | 2 | 0 | 0 | 24.064 | 3.997 | 2 | 0 |
| 4 | 2 | 0 | 23.440 | 4.020 | 4 | 0 | 0 | 24.071 | 4.002 | 4 | 0 |
| 4 | 3 | 0 | 23.438 | 4.020 | 6 | 0 | 0 | 24.065 | 4.002 | 6 | 0 |
| 4 | 4 | 0 | 23.435 | 4.020 | 8 | 0 | 0 | 24.064 | 4.001 | 8 | 0 |
| 4 | 6 | 0 | 23.419 | 4.020 | 12 | 0 | 0 | 24.061 | 4.001 | 12 | 0 |
| 4 | 8 | 0 | 23.427 | 4.020 | 16 | 0 | 0 | 24.070 | 4.001 | 16 | 0 |
| 4 | 12 | 0 | 23.422 | 4.020 | 24 | 0 | 0 | 24.066 | 4.000 | 24 | 0 |
| 4 | 16 | 0 | 23.420 | 4.020 | 32 | 0 | 0 | 24.071 | 4.000 | 32 | 0 |
| 4 | 24 | 0 | 23.426 | 4.020 | 48 | 0 | 0 | 24.064 | 3.998 | 65 | 0 |
| 4 | 32 | 0 | 23.423 | 4.020 | 64 | 0 | 0 | 24.065 | 3.997 | 100 | 0 |
| 4 | 48 | 0 | 23.443 | 4.017 | 96 | 0 | 0 | 24.065 | 4.004 | 152 | 0 |
| 4 | 64 | 0 | 23.422 | 4.020 | 128 | 0 | 0 | 24.062 | 5.003 | 242 | 0 |
| 4 | 96 | 0 | 23.359 | 4.020 | 234 | 0 | 19 | 24.069 | 6.068 | 477 | 0 |
| 4 | 128 | 29 | 23.422 | 4.019 | 358 | 0 | 54 | 24.061 | 5.742 | 694 | 0 |
| 4 | 192 | 92 | 23.432 | 4.020 | 592 | 0 | 113 | 24.066 | 6.083 | 1499 | 0 |
| 4 | 256 | 179 | 24.063 | 5.951 | 2381 | 0 | |||||
| 4 | 384 | 307 | 23.924 | 6.036 | 5033 | 0 | |||||
| 4 | 512 | 422 | 24.046 | 6.751 | 8449 | 0 | |||||
| 4 | 768 | 687 | 24.038 | 6.731 | 18319 | 0 | |||||
| 4 | 1024 | 925 | 24.037 | 8.107 | 32798 | 4 | |||||
您也可以通过运行大量线程(从 1 秒至 128 秒)来观察线程资源缺乏。显然,运行 1 秒,操作系统可能来不及运行所有的 250 个线程。(我再次选择用 250 个线程来直接比较 Windows 和 Linux。也许某天,Linux 的分发商会将缺省最大线程数增加到 1024 左右。) 表 4 演示了下列命令的运行:
fract5-1 -s -t 250 1,128,2,1
表 4. 运行 fract5.cpp 以演示资源缺乏的结果
| Red Hat 7.3 | Windows 2000 Advanced Server | |||
| seconds | threads | unscheduled-threads | threads | unscheduled-threads |
| 1 | 250 | 233 | 250 | 222 |
| 2 | 250 | 216 | 250 | 212 |
| 3 | 250 | 200 | 250 | 229 |
| 4 | 250 | 165 | 250 | 216 |
| 6 | 250 | 154 | 250 | 190 |
| 8 | 250 | 158 | 250 | 145 |
| 12 | 250 | 109 | 250 | 157 |
| 16 | 250 | 67 | 250 | 137 |
| 24 | 250 | 36 | 250 | 44 |
| 32 | 250 | 3 | 250 | 35 |
| 48 | 250 | 0 | 250 | 0 |
| 64 | 250 | 0 | 250 | 0 |
最后,您可以下载并编译 fract5.cpp,并在任何多处理器机器上运行下列命令:
fract5 -s -t 1,32 1
您将非常清楚地了解到,您的系统上有多少处理器。我唯一一次这样运行的例子是在有四个物理 CPU 的 Intel“Foster”机器上。运行显示性能提高了八倍(因为每个物理处理器有两个虚拟处理器)。我建议您自己尝试这种效果。
本文描述的两个程序:csfast5a.cpp 演示了如何用线程来测量上下文切换次数,fract5.cpp 完成了“伪”实际工作。第一个程序 csfast5a.cpp 演示了当线程准备好运行时通过增加成功可能性来减少总执行时间。我们通过增加每个线程所需的资源池(锁)来做到这一点。第二个程序 fract5.cpp 完成“实际”工作并演示了如果您有许多线程要在很短时间内运行,则有些线程可能不会被调度。在 1 至 64 秒的时间内运行大量线程确切地演示了这一点。
建议您下载 csfast5a.cpp 和 fract5.cpp(请参阅 参考资料),并在您的硬件上运行它们。在真正的多处理器机器上运行这两个程序,并将您的结果发送给我。请记得告诉我您的 CPU 的型号和数目。
- 下载本专栏文章中提到的程序的源代码:
- 阅读 Ed 先前的“RunTime”专栏文章:
- 在
developerWorksLinux 专区找到更多
Linux 文章。

Edward Bradford 博士现在为 IBM Software Group 管理 Microsoft Premier Support,并且每周还为 “Linux 和 Windows 2000 软件开发者”撰写新闻简报。可以通过 egb@us.ibm.com与他联系。