1.10 双线程高效下载
我们经常需要编写程序,从网络上下载数据,然后存储到硬盘上。一个简单的做法,就是下载一块数据,写入硬盘,然后再下载,再写入硬盘……不断重复这个过程,直到所有的内容下载完毕为止。能否对此进行优化?
我们不妨对问题做一些抽象和简化。
1.假设所有数据块的大小都是固定的。你可以使用一个全局缓存区:
Block g_buffer[BUFFER_COUNT]
2.假设两个基本函数已经实现(你可以假定两个函数都能正常工作,不会抛出异常):
//downloads a block from Internet sequentially in each call // return true, if the entire file is downloaded, otherwise false. bool GetBlockFromNet(Block * out_block); //writes a block to hard disk bool WriteBlockToDisk(Block * in_block);
上述的想法可以用代码清单1-15中的伪代码实现。
代码清单1-15
while(true) { bool isDownloadCompleted; isDownloadCompleted=GetBlockFromNet(g_buffer); WriteBlockToDisk(g_buffer); if(isDownloadCompleted) break; }
可以看到,在上述方法中,我们要下载完一块数据之后才能写入硬盘。下载数据和写入硬盘的过程是串行的。为了提高效率,我们希望能够设计两个线程,使得下载和写硬盘能并行进行。
线程A:从网络中读取一个数据块,存储到内存的缓存中。
线程B:从缓存中读取内容,存储到文件中。
试实现如下子程序:
1.初始化部分
2.线程A
3.线程B
你可以使用下面的多线程API(如代码清单1-16)。
代码清单1-16
class Thread { public: // initialize a thread and set the work function Thread(void (*work_func)()); // once the object is destructed, the thread will be aborted ~Thread(); // start the thread void Start(); // stop the thread void Abort(); }; class Semaphore { public: // initialize semaphore counts Semaphore(int count, int max_count); ~Semaphore(); // consume a signal (count--), block current thread if count==0 void Unsignal(); // raise a signal (count++) void Signal(); }; class Mutex { public: // block thread until other threads release the mutex WaitMutex(); // release mutex to let other thread wait for it ReleaseMutex(); };
如果网络延迟为L1,磁盘I/O延迟为L2,将此多线程与单线程进行比较,分析这个设计的性能问题,并考虑是否还有其他改进的设计方法?
分析与解法
这道题目出现在2007年微软校园招聘的笔试中。出题者为了让不同知识背景的同学都能发挥水平,特地提供了详细的说明。这样,没有接触过实际的Windows多线程编程的同学也能写出代码。现在越来越多的电脑采用双核甚至是多核的体系结构,并行计算会成为常用的程序工作模式。这道题只是一个简单的例子。
在实际工作中,程序员经常要依靠已有的模块和API完成任务,这些模块也许只有简单的接口说明,没有源代码。在这种情况下能够高效地完成任务,也是优秀程序员的特质之一。
我们需要使用两个线程来完成从网络上下载数据并存储到硬盘上的过程。下载线程和存储线程共享一个全局缓存区,我们需要协调两个线程的工作。下面若干因素是我们要重点考虑的。
1.什么时候才算完成任务?
两个线程必须协同工作,将网络上的数据下载完毕并且完全存储到硬盘上,只有在这个时候,两个线程才能正常终止。
2.为了提高效率,希望两个线程能尽可能地同时工作。
如果使用Mutex,下载和存储线程将不能同时工作。因此,Semaphore是更好的选择。
3.下载和存储线程工作的必要条件如下。
如果共享缓存区已满,没有缓冲空间来存储下载的内容,则应该暂停下载。如果所有的内容都已经下载完毕,也没必要继续下载。
如果缓存区为空,则没必要运行存储线程。进一步,如果下载工作已经完成,存储线程也可以结束了。
4.共享缓存区的数据结构。
下载线程和存储线程工作的过程是“先进先出”,先下载的内容要先存储,这样才能保证内容的正确顺序。“先进先出”的典型数据结构是队列。由于我们采用了固定的缓冲空间来保存下载的内容,循环队列会是一个很好的选择。
综合考虑上面的因素,调用题目提供的API可以得到下面这个可供参考的伪代码(如代码清单1-17所示)。
代码清单1-17
#define BUFFER_COUNT 100 Block g_buffer[BUFFER_COUNT]; Thread g_threadA(ProcA); Thread g_threadB(ProcB); Semaphore g_seFull(0, BUFFER_COUNT); Semaphore g_seEmpty(BUFFER_COUNT, BUFFER_COUNT); bool g_downloadComplete; int in_index=0; int out_index=0; void main() { g_downloadComplete=false; threadA.Start(); threadB.Start(); // wait here till threads finished } void ProcA() { while(true) { g_seEmpty.Unsignal(); g_downloadComplete=GetBlockFromNet(g_buffer+in_index); in_index=(in_index+1) % BUFFER_COUNT; g_seFull.Signal(); if(g_downloadComplete) break; } } void ProcB() { while(true) { g_seFull.Unsignal(); WriteBlockToDisk(g_buffer+out_index); out_index=(out_index+1) % BUFFER_COUNT; g_seEmpty.Signal(); if(g_downloadComplete && out_index==in_index) break; } }
上面的伪代码中,ProcA和ProcB的操作能够一一对应起来,下载线程和存储线程可以同时工作,看起来很美。如果网络延迟为L1,磁盘I/O延迟为L2,那么这个多线程程序执行的时间约为Max(L1, L2)。尤其是需要下载的内容很多的时候,基本可以保证两个线程是流水线工作。而在单线程的情况下,需要的时间是L1+L2。
如果网络延迟远大于I/O存储延迟,则多个下载线程的设计将可以进一步改善性能。但也将带来一些更复杂的问题。多个下载线程和存储线程之间如何协同工作呢?这个问题留给读者思考。
微软亚洲研究院的研发主管邹欣曾经参加过多个版本Microsoft Outlook的开发,他提供了这个题目,并且讲了下面的故事。
在Outlook和Exchange服务器连接的情况下,Outlook需要下载一系列的“离线地址簿文件”(OAB, Offline Address Book)以支持Outlook在离线的情况下还能正常地搜索到公司所有E-mail用户和用户组的信息。这个文件相当大,对于Microsoft这样的公司来说,大概有300MB~500MB,原来的算法是单线程的(正如题目所示),运行要花很长时间,用户抱怨他们不得不盯着这个对话框的进度条缓慢移动,非常不爽。于是我写了一个双线程的方案,在经过反复测试,并且得到测试人员的认可后,我把修改正式提交到源代码库。几天后,同事们高兴地反映,新的版本的确快多了!但是又有另两位同事抱怨,这个功能太“狠”了,在笔记本电脑上,Outlook像疯了一样地写硬盘,他们都做不了其他事情!后来几经讨论和试验,我们做了进一步修改,如果用户正在使用电脑,那么双线程会自动放慢速度,如果发现用户在几秒钟内没有鼠标、键盘的输入,那双线程会逐渐恢复高速运行。从那以后,我就再也没有听到类似的抱怨了。也许提高程序效能(performance)的最高境界,就是把事情做了,同时又不让用户感觉到程序在费力地做事情。
最近发布的Windows桌面搜索(Desktop Search)也有类似的“人性化”功能,如果你正在使用电脑,它会提示“Index speed is reduced while you're using the computer”,那么Windows中的哪些API能了解用户是否在使用鼠标或者键盘呢?这个问题留给读者去探索。