很多很多年前,有个叫 DOS 的操作系统。
DOS 通过一行一行命令运行程序。在同一时刻里,你只可能运行一个程序,这就是 单进程系统。
后来出现了 Windows,用户可以在系统中打开多个程序并使用它们。这就是 多进程系统。
线程 与 进程 的关系,就如同 进程 与 系统 的关系。一个 系统 可以存在多个 进程 ,一个 进程 也可以存在多个 线程 。
今天的主题与 多线程 的原理关系不大,因此就不在其 原理 上进行更多的说明和解释了。
什么是单线程,什么是多线程
还得记大约五、六年前,我去 KFC 和 McDonald's 就发现了一个有趣的区别。
在 KFC 中,收银 与 配餐 是同一人。
顾客在点餐后,继续站在原地等待西餐,造成了 KFC 中常常能见到长长的队伍在排队。
在 McDonald's ,这两件事是由两个不同的人负责的。
顾客在点餐完成后,带着号离开点餐的队伍,直接去等待配餐叫号。点餐的队伍人数比 KFC 明显少了许多。
对比这两种模式,你会发现
- KFC 的模式很容易积压一长排的顾客,让他们烦于等待而最终离开。
- McDonald's 的模式不容易产生排长队的顾客,并且可以让顾客早早的进入叫号等餐环节。
我们把 线程 视作 员工 ,把 顾客 视作 任务,于是这两个例子就可以非常形象的解释什么 单线程 ,什么是 多线程 。
- KFC 这种模式模式就是 单线程 , 一个任务从头至尾由一 线程 完成,在一个任务完成之前,不接受第二个任务。
- McDonald's 这种模式就是 多线程 , 将一个任务拆分成不同的阶段(部分),并交给不同的 线程 分别处理。
什么是同步,什么是异步
当你明白了 KFC 和 McDonald's 后,一切就很简单了。
线程 是 员工,同步 / 异步 就是顾客点餐的流程了。
- 顾客不能去一下洗手间,只能呆呆地站在那里等待配置的模式就是 同步 。
- 顾客支付以后,利用等待配置的时间去一下洗手间,找个座位的模式就是 异步 。
显而易见,异步 可以提供更高的效率,它可以利用 等待 的时间去完成一些事情。
在我们的代码中,一个 顾客 一边等待配置、一边做些别的事情,就是 多线程 了。
因此,(单/多线程) 与 (同/异)步 是密不可分的两个概念。
实现异步
在正常情况下,我们写出来的代码都是 同步 的,上一行代码没做完就肯定不会执行第二行。
所以 如何实现同步 这个问题的答案与 如何写一段代码 是一样的。
那么,我们自然而然的就把目光放在了 如何实现异步,这一话题上了。
在 .Net 中,我们有几种 异步 的实现方案 :
- Thread
- BeginInvoke
- Task
- async / await
下面,我会介绍每种方案是如何实现的
Thread
首先,如上面所提到的,异步 的目标就是,先开始 某个任务,然后利用等待的时间去做点 别的事情。
很明显,这里有两个线程
- 一个负责 某个任务。
- 另一个负责 别的事情,并在完成 别的事情 后开始等待 某个任务 的完成。
利用这个思想,我们可以自己做一个异步的小例子了。
// 某个任务的结果
int resultOfSomeTask = 0;
Thread someTask = new Thread(new ThreadStart(() =>
{
Thread.Sleep(1000);
resultOfSomeTask = 100;
}));
someTask.Start();
DoSomething(1); // 做一些别的事情
DoSomething(2); // 做一些别的事情
DoSomething(3); // 做一些别的事情
// 每隔一会儿去看看 【某个任务】 是否完成了
while (true)
{
if (someTask.ThreadState == ThreadState.Stopped)
break;
Thread.Sleep(1);
}
Assert.AreEqual(100, resultOfSomeTask);
代码说明
- 我们利用 Thread 创建一个线程,让它去完成 某个任务,我们模拟该任务需要 1秒钟,并会产生一个 100 的结果
- 使用 Thread.Start 开始这个任务
- 在 someTask 执行过程中,我们做作一些 别的事情
- 利用一个轮询查看 someTask 的状态,当完成后,我们发现已经得到了 100 这个结果。
上面例子中 while(true) 部分,我们可以使用 Thread.Join() 方法来代替以达到同样的效果。
// 某个任务的结果
int resultOfSomeTask = 0;
Thread someTask = new Thread(new ThreadStart(() =>
{
Thread.Sleep(1000);
resultOfSomeTask = 100;
}));
someTask.Start();
DoSomeThine(2); // 做一些别的事情
DoSomeThine(3); // 做一些别的事情
DoSomeThine(1); // 做一些别的事情
// 产生与 while(true) 同样的效果
// 当 someTask 完成后,才会继续进行
someTask.Join();
Assert.AreEqual(100, resultOfSomeTask);
这种异步的实现方式让开发者无法只关注在 逻辑 本身,代码中混入了大量的与线程有关的代码。
而且最不人性化的是,Thread 要么没有参数,要么只给一个 object 类型的参数,最草稿的是,它 无法返回结果,我们必须写一些额外的代码、逻辑去要主线程中得到子线程中的结果。
题外话
在实际生产环境中,我们往往使用 ThreadPool 来开启线程。
毕竟每开一个线程,系统都会产生一相应的消耗来支持它。
ThreadPool 可以开启有限个的线程,并对任务排队,仅当有线程空闲时,才会继续处理任务。
BeginInvoke
BeginInvoke 是 Delegate 上的一个成员,它与 EndInvoke 一起使用可以实现异步操作。
- BeginInvoke 相当于上面例子中 Thread.Start() 的功能
- EndInvoke 相当于上面例子中 Thread.Join() 的功能
因为 BeginInvoke 是 Delegate 上的成员,所以我们先声明一个 Delegate
/// <summary>
/// 这是一个描述了一个使用整形并返回整形的委托类型。
/// 你可以使用直接使用 Func<int,int> 来作为委托的类型。
/// </summary>
/// <param name="i"></param>
/// <returns></returns>
public delegate int TaskGetIntByInt(int i);
BeginInvoke 的入参比较特别,它分为两个分部。
- 前面的几个参数,就是委托中定义的参数
- 后面两个参数,一个是异步任务完成时的回调,一个是可以向回调函数传入的额外参数,你可以传递任何你需要的内容至回调里,而避免了在进程内访问进程外成员的情况
下面是一个 BeginInvoke 的例子
// 这是一个耗时1秒的任务,会返回入参的平方数
TaskGetIntByInt someTask = new TaskGetIntByInt(i =>
{
Thread.Sleep(1000);
return i * i;
});
// 定义一个函数,用于 someTask 完成时的回调
AsyncCallback callback = new AsyncCallback(ar =>
{
string state = ar.AsyncState as string;
Assert.AreEqual("Hello", state);
});
// 开始平方数运算的任务
// callback, "HelloWorld" 根据需求传入,你也可以传 null
IAsyncResult ar = someTask.BeginInvoke(10, callback, "HelloWorld");
// 开始一些别的任务
DoSomeThing(1);
DoSomeThing(2);
DoSomeThing(3);
// 等待 someTask 的运算结果,形如 Thread.Join()
int result = someTask.EndInvoke(ar);
Assert.AreEqual(100, result);
代码说明
首先 创建委托的实例,你可以使用其它类型上的成员来构造,也可以像示例中那样直接写一个内部方法。
接下来 使用 BeginInvoke 开始异步调用。 注意 这里返回了一个 IAsyncResult 类型。
你可以把这个 IAsyncResult 理解为你在 McDonald's 点好餐后的 号 , 每个人的 号 都是不同的,每个顾客都可以用这个 号 领取你的美食。
在代码中,每次调用 BeginInvoke 都会产生不同的 IAsyncResult,你可以用不同的 IAsyncResult 去获取它们对应的结果。
BeginInvoke 的时候,你还可以指定一个 回调函数 ,还可以指定一个变量,供 回调函数 使用。
此时,someTask 已经在子线程中运行了。同时,主线程继续执行了 3 个 DoSomething() 方法。
当你需要 someTask 的运行结果时,你只需要调用 someTask.EndInvoke(IAsyncResult) 。
- 当子线程已经完成后,调用 EndInvoke 你可以立即得到结果。
- 当子线程尚未完成时,调用 EndInvoke 会一直等待,等到子线程执行完成后,才可以得到结果。
题外话
若你的异步任务是一个耗时极长的任务,在主线程使用 EndInvoke 会 傻等 很久。
此时,你可以将 EndInvoke 方法在 Callback 内执行。
将 someTask 作为 回调函数 的参数传入,就可以在 Callback 内使用 EndInvoke 得到结果。
TaskGetIntByInt someTask = new TaskGetIntByInt(i =>
{
Thread.Sleep(3000);
return i * i;
});
DoSomeThing(1);
DoSomeThing(2);
DoSomeThing(3);
AsyncCallback callback = new AsyncCallback(_ar =>
{
// BeginInvoke 的最后一位参数可以通过 AsyncState 取得
TaskGetIntByInt task = (TaskGetIntByInt)_ar.AsyncState;
int result = task.EndInvoke(_ar);
Assert.AreEqual(100, result);
});
IAsyncResult ar = someTask.BeginInvoke(10, callback, someTask);
对于一个 异步 任务的结果,我们往往有两种方法处理 :
- 在主线程中等待结果
- 在子线程中处理结果
对于一个耗时较短的任务,我们可以先利用 异步 将该任务放在子线程中执行。再继续在主线程中处理其它任务,最后等待 异步 任务的完成。这种方式就是 在主线程中等待结果 。
对于一个耗时较长的任务,如果在主线程中 等待 会有可能对终端用户带来不好的应用体验。
因此,我们不会在主线程中等待 异步 的完成。
我们在 异步 任务开启后,就可以早早通知用户 你的任务正在处理中。同时在子线程中,当任务完成后,你可以利用数据库等手段,将 正在处理中的任务 标为 已完成,并通知他们。
Task
从 .Net4.0 开始,Task 成为了实现 异步 的主要利器。
Task 的用法与 JavaScript 中的 Promise 非常接近。
Task 表示一个 异步 任务。废话不多说,我们先写一个返回 Task 的方法。
// Task<int> 表示这是一个返回 int 类型的 Task
private Task<int> AsyncPower(int i)
{
// 返回一个任务
// 使用 Task.Run 相当于先创建了一个 Task
// 再 Start
return Task<int>.Run(() =>
{
// 该任务会耗时 1 秒钟
Thread.Sleep(1000);
// 1 秒钟后会返回参数的平方
return i * i;
});
}
与之前提到的相同,我们有两种方法处理这个 Task 的结果 :
- 在主线程中等待结果
- 在子线程中处理结果
我们看看两种模式分别是如何实现的
在主线程中等待结果
直接访问 Task.Result 属性,就可以等待并得到 异步 任务的结果。
var task = AsyncPower(10);
// 这里会等 1 秒
int result = task.Result;
// result = 100
怎么样 ? 是不是超级简单 ?
在子线程中处理结果
使用方法 ContinueWith 可以添加一个方法,在 Task 完成后被执行。
这个 ContinueWith 和 JavaScript 里的 Promise 的 then 方法有着异曲同工的效果。
var task = AsyncPower(10);
task.ContinueWith(t =>
{
int result = t.Result;
// result = 100
});
怎么样 ? 是不是依然超级简单 ?
就像之前说的,Task 用起来就像 Promise,Promise 最大的特点就是可以用一步一步 then 下去。
$.get("someurl")
.then(rlt => foo(rlt))
.then(rlt => bar(rlt));
Task 的 ContinueWith 也支持这样的编写方法 :
var task = AsyncPowe(10);
task.ContinueWith(t =>
{
// some code here
}).ContinueWith(t =>
{
// some code here
}).CondinueWith(t => {
// some code here
});
async / await
这个 .Net 4.5 加入的关键字,让 异步代码 写起来和 同步代码 没什么区别了。
我们先看看下面的同步代码
int Power(int i)
{
return i * i;
}
void Main()
{
int result = Power(10);
Console.WriteLine(result); // 100
Console.ReadLine();
}
把上面的代码改成异步代码,只需要几个小小的改动 :
- 将 Power 的返回值改为 Task<T>
- 修改返回结果,使用 Task.Run 包装返回的结果
- 为调用 Power 的代码前加上 await 关键字
- 为有 await 关键字的方法加上 async 关键字
新的代码如下
Task<int> Power(int i)
{
return Task<int>.Run(()=>
{
Thread.Sleep(100); // 模拟耗时
return i * i;
});
}
async void Main()
{
Console.WriteLine("Hello");
int result = await Power(10);
Console.WriteLine(result); // 100
Console.ReadLine();
}
运行一下,发现没什么区别。
如果你向控制台输出线程ID的话,你会发现 Console.WriteLine("Hello") 和 Console.WriteLine(result) 并不工作在同一个线程同。
为什么会有这样的效果 ?
因为 编译器,你会发现,和之前的异步实现不同,async 和 await 不是某个封装了复杂逻辑的类型,而是两个关键字。
关键字的意义就是编译过程。
在编译时,遇到 async 就会知道这是一个存在着异步的方法,编译器向这个类型添加一些额外的成员来支持 await 操作。
当遇到 await 关键字时,编译器会从当前行截断,并向后面的代码编译到 Task.ContinueWith 中去。
这样一来,看似同步的代码,经编译后,就会一拆为二。
前部分运行在主线程中,后部分运行在子线程中,分割点就是 await 所在的代码行。
慎用异步
几种在 .Net 平台中使用 异步 的方法都介绍完了,希望大家能够对 异步 编程有了一定的了解和认识。
但是,在实际生产中,依赖要慎用异步。
异步 在带来性能提高的同时,还会带来一些更复杂的问题:
线程安全
线程间的切换并不是有着类似 事务 的特征,它无法保证两个线程对同一资源的读写的完整性。
而且大部分情况下,一个线程操作完,就会被挂机执行另一个线程,所以对于多个线程访问同一资源,需要考虑线程安全的问题。
换句话说,就是保证一个线程在执行一个最小操作时,另一个线程不允许操作该对象。
调试难
异步 的本质就是 多线程 ,当你尝试用断点调试代码时,由于两个线程都在你的代码中运行,因此常常出现从这个线程的断点进入另一个线程的断点的情景。
需要依赖 IDE 中更多的工具和设置,才能解决上述的问题。
不统一的上下文
异步 代码往往在子线程中运行。
子线程 很可能会使用在 主线程 中已经施放的资源。
比如
using(var conn = new SqlConnection("........"))
{
conn.Open();
// 假定一个根据用户名查询用户ID的方法
Tast<int> task = UserService.AsyncGetUserId(conn, "Admin");
task.ContinueWith(t =>
{
// 此时的 conn 已经被主线程释放了
UserService.DoSomethingWithConn(conn);
});
}
你需要使用一些额外的代码来解决这些问题。并且这些代码不一定具备通用性,往往要具体问题具体分析。
因此在实际任务中,到底选择 同步 还是 异步 要视具体情况而定。
今天本文介绍了几种实现 异步 的方法,不能说它们之间谁比谁更好一点,各有优劣。
篇幅原因,将不再对几种方案进行对比,会在以后的文章中详细地介绍各自优劣。