代码改变世界

【线程呓语】Thread

2011-01-08 10:09 横刀天笑 阅读(...) 评论(...) 编辑 收藏

在操作系统里有两个很重要的概念:进程和线程。一般,简单的理解进程就是一个应用程序在内存中的实例,比如你的word.exe安静的躺在硬盘上,然后你双击它,它启动了,加载到内存,这就创建了一个进程,你可以在任务管理器里看到这个进程的存在,然后你再打开一个word.exe,又一个进程创建了。一般,进程象征着数据资源,对应着内存,模拟出一个假象:整个计算机好像就只有OS和当前这个应用程序。

内存模拟出来了,那CPU呢?毕竟机器内的CPU core是有限的,大部分机器两个或四个。但是你可能想运行很多程序,那可怎么办,OS就会通过一种调度算法,一会儿让你执行一会儿,一会儿让另外一个程序执行一会儿,只要这个切换过程足够快,给我们的感觉就好像是所有的程序都在同时地执行。那这个一会儿让你执行一会儿,一会儿让别人执行一会儿就需要抽象,不然不要把你的代码分成一段段的么,这个抽象的结果就是线程,你的代码由一个线程执行,这给你的感觉就好像是你的代码一直占用着整个CPU,不过实际上是CPU在不同的线程中频繁的切换(context switch)。

我们知道,要创建快速响应的桌面软件,我们就得将一些耗时操作,比如访问远程数据库,访问大文件等放到与UI主线程不同的线程里去做,免得阻塞主线程,造成UI挂起的现象,用户以为程序挂了。如是我们就以为Thread是一剂良方,可以充分利用CPU的资源。如是:

   1: public void btn_Click(object sender,EventArgs e)
   2: {
   3:     ThreadStart start = new ThreadStart(GetBlogsFromDB);
   4:     Thread thread = new Thread(start);
   5:     thread.Start();
   6: }
   7:  
   8: public void GetBlogsFromDB()
   9: {
  10:     //...access remote database
  11: }

 

好,这样我们就搭上了多线程的轻轨列车,UI响应快了,即使是要访问远程数据库,我们的UI还是响应灵敏,鼠标也不转圈儿。不过在高兴之余,我们先来看看创建一个线程有哪些开销呢:

1、Thread kernel object 操作系统为每个线程分配一个这样的数据结构,里面还包括线程的上下文。线程的上下文包括当前CPU寄存器的值。在x86 CPU上线程上下文大概是700个字节,x64是1240,IA64是2500个字节。

2、Thread environment block(TEB) TEB包括一页的内存(页,在x86和x64上是4KB,在IA64上是8KB),它是在用户模式下初始化的。TEB包括线程的异常处理链。线程每进入一个try,就会在这个链子头部上插一条,退出这个try的时候就会删掉这一条。TEB还包含有thread local storage,以及一些用于GDI的数据结构。

3、User mode stack 一般也叫做线程栈,用于保存传递给方法的参数,以及方法内的局部变量和方法的返回地址。默认情况下,在Windows上为每个线程分配1MB的线程栈(这也就是为什么不良的递归方法会造成栈溢出,因为不断地递归,方法参数,局部变量和方法返回地址占用内存不断地增加,如果超过1MB,就会造成溢出了)。这里对于托管代码和非托管代码有个区别,在非托管代码,比如C/C++中,创建一个线程windows只是保留1MB的地址空间,只有等到这个线程真的需要的时候才提交,而托管代码中只要线程创建了就会提交,然后分配1MB的物理内存。

4、Kernel mode stack 应用程序的代码经常需要调用操作系统的内核模式的函数。基于安全的考量,OS就会把调用的参数从user mode stack拷贝到kernel mode stack,拷贝完了后OS会对这些参数进行检验。除此之外,内核模式里的方法也要互相调用,它们就靠这个栈保存局部变量,方法参数和返回地址。在32位系统上这个栈占12KB,64位系统上24KB。

5、DLL thread-attach and thread-detach notifications 当进程中创建了一个新线程,该进程里加载的所有DLL的DllMain方法都会被调用,传递一个DLL_THREAD_ATTACH标记,当有一个线程死了,也会被调用。不过对于C#以及其他很多托管语言编写的dll,因为没有DllMain方法,所以不会收到这通知。

上面列出的是创建线程或灭掉一个线程所需要的开销,看来线程的创建也挺昂贵。不过这还是小事,前面也提到过,因为我们的CPU是有限的,OS只是通过频繁的线程切换给我们造成所有程序都在同时运行的假象。这个线程切换就是传说的context switch。在给定的时间,windows分配一个线程给CPU,然后让它运行一个时间片,当时间片到期了,然后让下一个线程运行。那么为了让调度程序又调度到这个线程后,它还能接着上次停止的那个地方运行我们就得做些特别的操作:

1、 在当前运行得线程的kernel object中的上下文结构中保存当前CPU寄存器的值。

2、 调度下一个线程运行,如果这个线程属于别的进程,windows还要先切换虚拟地址空间。

3、 将所选择线程的上下文结构加载到CPU寄存器。

其实上面3个步骤还不算糟糕,糟糕的是如果调度到其他线程,那么CPU的cache里保存的前一个线程的代码和数据将完全失效,又需要直接从内存中访问。

那这儿就造成一个困境,一方面我们需要创建线程来创建更健壮更灵敏的界面,另一方面创建线程的开销非常大,而且一旦创建了很多线程,就必定要在这些线程之间切换,又会带来上下文切换的开销。

这个困境我们在数据库访问的时候也碰到过:

数据库的连接非常昂贵,所以我们需要打开连接访问数据后,立即关闭,但是创建一个连接也非常昂贵,那个时候有个数据库连接池的概念出现了。这里,我们也会有一个线程池的东东来拯救我们。嗯,这是后话。

 

后记

最近对并行和异步非常感兴趣,可惜对这些计算机基础理论的东西掌握的不够牢固,所以捡起来看看。如果有什么问题请大家不吝赐教,或者有一些并行和异步的论文也分享出来,让我拜读拜读,谢了~~