.NET面试题系列[17] - 多线程概念(2)
线程概念
线程和进程的区别
- 进程是应用程序的一个实例要使用的资源的一个集合。进程通过虚拟内存地址空间进行隔离,确保各个进程之间不会相互影响。同一个进程中的各个线程之间共享进程拥有的所有资源。
- 线程是系统调度的基本单位。时间片和线程相关,和进程无关。
- 一个进程至少要拥有一个前台线程。
线程开销
当我们创建了一个线程后,线程里面主要包括线程内核对象、线程环境块、1M大小的用户模式栈和内核模式栈。
- 线程内核对象:如果是内核模式构造的线程,则存在一个线程内核对象,包含一组对线程进行描述的属性,以及线程上下文(包含了CPU寄存器中的数据,用于上下文切换)。
- 线程环境块:用户模式中分配和初始化的一个内存块。
- 用户模式栈:对于用户模式构造的线程,应用程序可以直接和用户模式栈沟通。
- 内核模式栈:如果是内核模式构造的线程进行上下文切换和其他操作时,需要调用操作系统的函数。此时需要使用内核模式栈向操作系统的函数传递参数。应用程序代码无法直接访问内核模式栈,它需要借助用户模式的代码。
线程有自己的线程栈,大小为1M,所以它可以维护自己的变量。线程是一个新的对象,它会增加系统上下文切换的次数,所以过多的线程将导致系统开销很大。例如outlook会创建38个线程,但大部分时候他什么都不做。所以我们白白浪费了38M的内存。
单核CPU一次只能做一件事,所以系统必须不停的进行上下文切换,且所有的线程(逻辑CPU)之间共享物理CPU。在某一时刻,系统只将一个线程分配给一个CPU。然后,该线程可以运行一个时间片(大约30毫秒),过了这段时间,就发生上下文切换到另一个线程。
假设某个应用程序的线程进入无限循环,系统会定期抢占他(不让他再次运行)而允许新线程运行一会。如果新线程恰好是任务管理器的线程(此时将会发现任务管理器可以响应,而任务管理器之外屏幕其他地方则仍然无响应),则用户可以利用任务管理器杀死包含了其他已经冻结的线程的进程。通过这种做法,上下文切换开销并不会带来任何性能增益,但换来了好得多的用户体验(很难死机,用户可以用任务管理器杀死其他的进程)。
当某个线程一直空闲(例如一个开启的记事本但长时间无输入)时,他可以提前终止属于他的时间片。线程也可以进入挂起状态,此时之后任何时间片,都不会分配到这个线程,除非发生了某个事件(例如用户进行了输入)。节省出来的时间可以让CPU调度其他线程,增强系统性能。
线程的状态
可以用下图表示:
线程的主要状态有四种:就绪(Unstarted),运行(Running),阻塞(WaitSleepJoin)和停止(Stopped),还有一种Aborted就是被杀死了。通常,强制获得线程执行任务的结果,或者通过锁等同步工具,会令线程进入阻塞状态。当得到结果之后,线程就解除阻塞,回到就绪状态。
当建立一个线程时,它的状态为就绪。使用Start方法令线程进入运行状态。此时线程就开始执行方法。如果没有遇到任何问题,则线程执行完方法之后,就进入停止状态。
阻塞(WaitSleepJoin),顾名思义,是使线程进入阻塞状态。当一个线程被阻塞之后,它立刻用尽它的时间片(即使还有时间),然后CPU将永远不会调度时间片给它直到它解除阻塞为止(在未来的多少毫秒内我不参与CPU竞争)。主要方式有:Thread.Join(其他线程都运行完了之后就解除阻塞),Thread.Sleep(时间到了就解除阻塞),Task.Result(得到结果了就解除阻塞),遭遇锁而拿不到锁的控制权(等到其他线程释放锁,自己拿到锁,就解除阻塞)等。当然,自旋也是阻塞的一种。
Thread类中的方法对线程状态的影响
Start:使线程从就绪状态进入运行状态
Sleep:使线程从运行状态进入阻塞状态,持续若干时间,然后阻塞自动解除回到运行状态
Join:使线程从运行状态进入阻塞状态,当其他线程都结束时阻塞解除
Interrupt:当线程被阻塞时,即使阻塞解除的要求还没有达到,可以使用Interrupt方法强行唤醒线程使线程进入运行状态。这将会引发一个异常。(例如休息10000秒的线程可以被立刻唤醒)
Abort:使用Abort方法可以强行杀死一个处于任何状态的线程
时间片
当我们讨论多任务时,我们指出操作系统为每个程序分配一定时间,然后中断当前运行程序并允许另外一个程序执行。这并不完全准确。处理器实际上为进程分配时间。进程可以执行的时间被称作“时间片”或者“限量”。时间片的间隔对程序员和任何非操作系统内核的程序来说都是变化莫测的。程序员不应该在他们的程序中将时间片的值假定为一个常量。每个操作系统和每个处理器都可能设定一个不同的时间。
进程和线程优先级
Windows是一个抢占式的操作系统。在抢占式操作系统中,较高优先级的进程总是抢占(preempt)较低优先级的进程(即使时间片没有用完)。用户不能保证自己的线程一直运行,也不能阻止其他线程的运行。
每一个进程有一个优先级类,每一个线程有一个优先级(0-31)。较高优先级的进程中的较高优先级的线程获得优先分配时间片的权利。
只要存在可以调度的高优先级的线程,系统就永远不会将低优先级的现场分配给CPU,这种情况称为饥饿。饥饿应该尽量避免,可以使用不同的调度方式,而不是仅仅看优先级的高低。在多处理器机器上饥饿发生的可能性较小些,因为这种机器上,高优先级的线程和低优先级的线程可以同时运行。
Thread类中的Priority允许用户改变线程的优先级(但不是直接指定1-31之间的数字,而是指定几个层级,每个层级最终mapping到数字,例如层级normal会映射到4)
前台和后台线程
一个进程可以有任意个前台和后台线程。前台线程使得整个进程得以继续下去。一个进程的所有前台线程都结束了,进程也就结束了。当该进程的所有前台线程终止时,CLR将强制终止该进程的所有后台线程,这将会导致finally可能没来得及执行(从而导致一些垃圾回收的问题)。解决的方法是使用join等待。例如你在main函数中设置了一个后台线程,然后让其运行,假设它将运行较长的时间,而此后main函数就没有代码了,那么程序将立刻终止,因为main函数是后台线程。
使用thread类创建的线程默认都是前台线程。Thread的IsBackground类允许用户将一个线程置为后台线程。
多线程有什么好处和坏处?
好处:
- 更大限度的利用CPU和其他计算机资源。
- 当一条线程冻结时,其他线程仍然可以运行。
- 在后台执行长任务时,保持用户界面良好的响应。
- 并行计算(仅当这么做的好处大于对资源的损耗时)
坏处:
- 线程的创建和维护需要消耗计算机资源。(使用线程池,任务来抵消一部分损失)。一条线程至少需要耗费1M内存。
- 多个线程之间如果不同步,结果将会难以预料。(使用锁和互斥)
- 线程的启动和运行时间是不确定的,由系统进行调度,所以可能会造成资源争用,同样造成难以预料的结果。(使用锁和互斥,或者进行原子操作)
为了避免2和3,需要开发者更精细的测试代码,增加了开发时间。
System.Threading类的基本使用
创建线程
可以使用Thread的构造函数创建线程。我们要传递一个方法作为构造函数的参数。通常我们可以传递ThreadStart委托或者ParameterizedThreadStart委托。后者是一个可以传递输入参数的委托。两个委托都没有返回值。ThreadStart委托的签名是:public delegate void ThreadStart();
1 基本例子:通过Thread构造函数建立一个线程。传递的方法WriteY没有返回值,也没有输入。之后使用Start方法使线程开始执行任务WriteY。
class ThreadTest { static void Main() { Thread t = new Thread (WriteY); t.Start(); for (int i = 0; i < 1000; i++) Console.Write ("x"); } static void WriteY() { for (int i = 0; i < 1000; i++) Console.Write ("y"); } }
这个例子中,主线程和次线程同时访问一个静态方法(静态方法是类级别的)。此时系统调度使得主线程和次线程轮流运行(但运行的顺序是随机的)。所以结果可能是
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2 主线程和次线程分别维护各自的局部变量
static void Main() { new Thread (Go).Start(); Go(); } static void Go() { // Declare and use a local variable - 'cycles' for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?'); }
次线程有自己的线程栈(大小1兆),所以主线程和次线程分别拥有各自的局部变量cycles。结果将是十个问号。这十个问号出自主线程和次线程,顺序不定。
3 主线程和次线程分享全局变量
class ThreadTest { bool done; static void Main() { ThreadTest tt = new ThreadTest(); // Create a common instance new Thread (tt.Go).Start(); tt.Go(); } // Note that Go is now an instance method void Go() { if (!done) { done = true; Console.WriteLine ("Done"); } } }
变量done是全局的,被所有线程共享。此时,次线程开始任务,并在Go方法中将done设为真。最后只会打印一个done。
何时考虑创建一个线程?
- 当创建线程的代价比线程池要小(例如只打算创建一个线程时)
- 当希望自己管理线程的优先级时(线程池自动管理)
- 需要一个前台线程(线程池创建的线程都是后台的)
向次线程传递数据
1. 使用Lambda表达式。此时仍然使用的是ThreadStart委托。
static void Main() { Thread t = new Thread ( () => Print ("Hello from t!") ); t.Start(); } static void Print (string message) { Console.WriteLine (message); }
2. 使用Thread的另一个构造函数传入一个ParameterizedThreadStart委托
ParameterizedThreadStart委托的签名是:public delegate void ParameterizedThreadStart (object obj);
所以它只能传递object类型的数据并且不能有返回值。
static void Main() { Thread t = new Thread (Print); t.Start ("Hello from t!"); } static void Print (object messageObj) { string message = (string) messageObj; // We need to cast here Console.WriteLine (message); }
捕获变量问题
由于lambda表达式形成闭包,导致有机会出现捕获变量。
for (int i = 0; i < 10; i++) new Thread (() => Console.Write (i)).Start();
上例的捕获变量:全世界只有一个i,所以被十条线程共用。
上面的代码形成了闭包,导致i成为捕获变量被十个匿名函数共享。出来的结果将是无法预料的。解决方法是在表达式内部声明变量,这将是匿名函数自己的变量。(此时循环增加一次就有一个temp所以每个线程有自己的变量)
for (int i = 0; i < 10; i++) { int temp = i; new Thread (() => Console.Write (temp)). Start(); }
Join:阻塞的是呼叫的线程
封锁呼叫的线程,直到其他线程结束为止。定义十分费解,看看例子。
例子1:Join阻塞的是呼叫的线程,在这个例子中呼叫的线程就是主线程。此时主线程将不会运行最后一行,直到次线程打印完了1000个y为止。
如果没有Join,则程序将立刻退出。
static void Main() { Thread t = new Thread (Go); t.Start(); t.Join(); Console.WriteLine ("Thread t has ended!"); } static void Go() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }
例子2:等待
static void Main(string[] args) { Thread t1 = new Thread(PrintOne); Thread t2 = new Thread(PrintTwo); Thread t3 = new Thread(PrintThree); t1.Start(); t2.Start(); t2.Join(); //等待其他线程运行完毕(这里只有t1需要等待) t1.Join(); t3.Start(); Console.ReadKey(); } static void PrintOne() { Console.WriteLine("One"); } static void PrintTwo() { Console.WriteLine("Two"); } static void PrintThree() { Console.WriteLine("Three"); }
将按顺序打印One, Two, Three。t2.Join()阻塞呼叫的线程t2,于是等待t1运行完毕。T1.Join()则没有要等待的线程。
Join可以设置一个timeout时间。
Sleep
让线程停止一段时间。呼叫Sleep或Join将阻塞线程,系统将不会为其分配时间片,所以不会耗费系统性能。特别的,Sleep(0)会将线程现在的时间片立刻用尽(即使还有剩余的时间)。
线程池
线程池是由CLR自动管理的,包含若干线程的集合。CLR利用线程池自动进行多线程中线程的创建,执行任务和销毁。利用任务或委托,可以隐式的和线程池发生关联。
线程池是如何管理线程的?
线程池的工作方法和普通的线程有所不同。他维护一个队列QueueUserWorkItem,当程序想执行一个异步操作时,线程池将这个操作追加到队列中,并派遣给一个线程池线程。线程池创建伊始是没有线程的。如果线程池中没有线程,就创建一个新线程。
相对于普通的使用Threading类创建线程,线程池的好处有:
- 线程池中创建的线程不会在执行任务之后销毁,而是返回线程池等待下一个响应,这样我们可以最大限度的重用线程。
- 线程池会尽量用最少的线程处理队列中的所有请求,只有在队列增加的速度超过了请求处理的速度之后,线程池才会考虑创建线程。
- 如果线程池中的线程空闲了一段时间,它会自己醒来终止自己以释放资源。
- 当同时运行的线程超过阈值时,线程池将不会继续开新的线程,而是等待现有的线程运行完毕。
线程池的缺点:
- 你不能为线程命名
- 线程池创建的线程一定是后台线程
C#运用了线程池的类和操作有:
- 任务并行库
- 委托
- BackgroundWorker
等等。
使用线程池:通过任务
我们可以通过创建一个任务来隐式的使用线程池:
static void Main() // The Task class is in System.Threading.Tasks { Task.Factory.StartNew (Go); } static void Go() { Console.WriteLine ("Hello from the thread pool!"); }
任务方法可以有返回值,我们可以通过访问Task.Result(会阻塞)来得到这个返回值。当访问时,如果任务执行中出现了异常,则我们可以将访问Task.Result写入try块来捕捉异常。
使用线程池:显式操作
我们可以通过显式操作ThreadPool.QueueUserWorkItem队列来操纵线程池,为它添加任务。我们还可以使用其的重载为任务指派输入变量。
static void Main() { ThreadPool.QueueUserWorkItem (Go); ThreadPool.QueueUserWorkItem (Go, 123); Console.ReadLine(); } static void Go (object data) { Console.WriteLine ("Hello from the thread pool! " + data); }
和任务有所不同,ThreadPool.QueueUserWorkItem的方法无法有返回值。而且,必须在方法的内部进行异常处理,否则将会出现执行时异常。
使用线程池:异步委托
异步委托是一种解决ThreadPool.QueueUserWorkItem没有返回值的方法。
static void Main() { Func<string, int> method = Work; IAsyncResult cookie = method.BeginInvoke ("test", null, null); // // ... here's where we can do other work in parallel... // int ret = method.EndInvoke (cookie); Console.WriteLine ("String length is: " + ret); } static int Work (string s) { return s.Length; }
异步调用一个方法也相当于给线程池派了一个新的任务。我们可以通过访问method.EndInvoke来获得访问结果。