一、多线程的术语
在学习多线程之前需要先理解有关多线程的术语。
- CPU(中央处理器)或内核/核心是实际执行程序的硬件单元。许多现代CPU都支持同时多线程(Intel称之为超线程),即使一个CPU能表现为多个「虚拟」CPU。
- 进程(process)是某个程序当前正在执行的实例。操作系统的一项基本功能就是管理进程。每个进程都包含一个或多个线程。
- 线程(thread)是操作系统能够进行运算调度的最小单位,也是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流。
- 单线程程序的进程仅包含一个线程;多线程程序则包含多个。
- 在多线程程序中运行具有正确行为的代码,就说代码是线程安全的。代码的线程处理模型是指代码向调用者提出的一系列要求,只有满足这些要求才能保障线程安全。
- 任务是可能出现高延迟的工作单元,作用是产生结果值或希望的副作用。它与线程的区别是:任务代表需要执行的一件工作,而线程代表做这件工作的工作者。
- 线程池是多个线程的集合,通过一定的逻辑决定如何为线程分配工作。当有任务要执行时,它分配池中的一个线程执行任务,任务结束后解除分配,从而使该线程在下次请求额外工作时可用。
二、多线程的实现
如果核心数量足够,每个线程都能分配到一个,那么每个线程都相当于在一台单独的机器上运行。但可惜大多数时候都是线程多、核心少。
为了解决这一矛盾,操作系统通过时间分片机制来模拟多个线程并发运行。即操作系统以极快的速度从一个线程切换到另一个线程,给人留下所有线程都在同时执行的错觉。处理器执行一个线程的时间周期称为时间片或量子。在某个核心上更改执行线程的行动称为上下文切换。
无论是真正的多核运行还是通过时间分片技术模拟,我们说一起进行的两个操作就是并发。实现这种并发操作需要以异步方式调用,被调用操作的执行和完成都独立于调用它的控制流。异步分配的工作与当前控制流并行执行,就实现了并发性。并行编程是指将一个问题分解成较小的部分,异步发起对每一部分的处理,最终使它们全部并发执行。
三、线程处理问题
多线程意味着原本在单一线程中成立的假设在多线程程序中不再成立,这就导致了如下所示的一系列问题。
非原子性
如果一个操作是原子操作,那意味着它要么尚未开始,要么已经完成。然而我们平时编程中的大多数操作都不是原子性的。比如下面这个购票程序
if (tickets > 0)
{
tickets--;
// 出票
// ...
}
如果假定有多个线程同时运行,他们恰好都通过了tickets > 0
的校验,又恰巧系统中只剩一张余票,那么就会只有一个人拿到真正的门票,而其他人拿到的都是虚假的门票。因为部分完成的非原子操作而造成了不一致状态,这是竞态条件的一种特例。
竞态条件造成的不确定性
在缺少线程同步的情况下,操作系统会在它认为合适的任何时间在任何两个线程之间切换上下文。结果就是当两个线程访问同一个对象时,无法预测哪个线程“竞争胜出”并抢先运行。
对于包含竞态条件的代码,其行为取决于上下文切换的时机。这就造成了程序执行的不确定性。有可能1000次执行里面只有1次异常状况。这使得竞态条件难以重现,所以需要依赖长期的压力测试、专业的代码分析工具及专家对代码进行大量分析和检查。此外,在多线程编程中,“越简单越好”也是一条重要的原则。
内存模型的复杂性
现代处理器不会在每次要用到一个变量时都去访问主存,而是在处理器的高速缓存中生成本地拷贝。该缓存定时与主存同步。这意味着如果两个线程在两个不同的处理器上,但都要访问同一个对象中的字段时,它们实际读取的可能不是对方那个位置的实时更新,从而得到了不一样的结果。这就是处理器同步缓存的时机产生了竞态条件。
死锁
为了对线程进行调度以防止竞态条件,开发者有必要对一部分代码进行加锁。即一次只能有一个线程执行这段代码,其他同时到达的线程将被挂起。但锁本身也存在问题,最容易发生的是死锁。当有两个线程都在等待对方释放它们的锁,只有在对方释放了锁之后才能继续,此时就发生了线程阻塞,造成代码彻底死锁。
四、线程类
4.1 Thread类
创建线程
要创建并启动一个线程,需要首先实例化Thread对象并调用Start方法。Thread的最简单的构造器接收一个ThreadStart委托:一个无参数的方法,表示执行的起始位置。
下面代码通过Thread
创建了一个线程,并在该线程中打印“A”。同时在主线程中打印“B”。
public static void ThreadPracticeMain()
{
Thread thread = new Thread(Print);
thread.Start();
for (int i = 0; i < 1000; i++)
{
Console.Write("B");
}
}
private static void Print()
{
for (int i = 0; i < 1000; i++)
{
Console.Write("A");
}
}
运行结果如下
阻塞线程
通过Thread.Sleep()
方法可以使当前线程暂停指定的时间
public static void ThreadPracticeMain()
{
Thread thread = new Thread(SleepTest);
thread.Start();
// 主线程睡眠1000毫秒
Thread.Sleep(1000);
Console.WriteLine("Main Thread Wake");
}
private static void SleepTest()
{
Console.WriteLine("Child Thread Start");
// 子线程睡眠3000毫秒
Thread.Sleep(3000);
Console.WriteLine("Child Thread Wake");
}
运行结果如下
需要注意的是,如果调用的是Thread.Sleep(0)
,会导致当前线程立即放弃自己的时间片,将CPU交给其他线程。这一点与Thread.Yield()
是相同的,只不过Thread.Yield()
只会将资源交给同一个处理器上运行的线程。
通过Thread.Join()
方法可以使当前线程阻塞,等待调用该方法的线程执行完毕后再唤醒。比如下面的例子中,Thread1线程调用了Thread2线程的Join()
方法,那么Thread1就会被阻塞,直到Thread2执行完毕。
private static Thread thread1, thread2;
public static void ThreadPracticeMain()
{
thread1 = new Thread(JointTest1);
thread1.Start();
thread2 = new Thread(JointTest2);
thread2.Start();
}
private static