创建线程是一个很代价很高的操作,每个异步操作创建线程都会对 CPU 产生显著的性能影响。为了解决这个问题我们引入了线程池的概念,所谓的线程池就是我们提前分配一定的资源,把这些资源放在资源池中,每次需要用到的使用从里面取出一个,用完后再放回去。线程池一般用在需要创建大量的短暂的且开销大的资源里。.NET 中的线程池位于 System.Threading.ThreadPool 类,它接受 CLR 的管理。 ThreadPool 类中拥有一个 QueueUserWorkItem 方法,该方法为静态方法。它接受一个委托,表示用户定义的异步操作。在方法被调用后,委托会进入到内部队列中。如果池中没有任何线程,将创建一个新的 Worker Thread (工作者线程)并将队列中第一个委托放入到该 Work Thread 中。
这里有一点要注意,当有新的操作加入到线程池里时,如果之前的操作完成了,那么这个新的操作将会重用线程来执行。但是如果新的操作加入线程池的太快太多,那么线程池将会创建更多的线程来执行操作。然后创建的线程数量是有限制的,达到限制的数量后,以后加进来的操作将会在队列中等待线程被放回线程池并有能力执行它们。当没有任何操作进入线程池中时,线程池会释放掉超过过期时间的线程,以减少操作系统和 CPU 的压力。
Tip:
- 一定不要在线程池中放入长时间运行的操作或者放入会阻塞线程的操作,这样会导致严重的性能问题和莫名其妙的bug。
- 线程池中的所有线程都是后台线程,当应用程序中的所有前台线程完成后后台线程也就停止工作,即使它还没有完成所作的工作。
零、 线程池中的 APM 和委托
所谓 APM 是异步编程模型,他是一种模式,该模式允许用更少的线程去做更多的操作,.NET Framework 很多类也实现了该模式。我们也可以在自定义的类中实现返回类型为 IAsyncResult 接口的 BeginXXX 方法 和 EndXXX 方法 。委托类型也定义了 BeginInvoke 和 EndInvoke 方法。下面我们即通过一个例子来看一下在线程池中怎么使用 APM 和委托。
using System;
using System.Threading;
using static System.Threading.Thread;
using static System.Console;
namespace APMAndInvoke
{
class Program
{
static void Main(string[] args)
{
int threadId = 0;
ThreadHotel threadHotel = Cooking;
IAsyncResult asyncResult = threadHotel.BeginInvoke(out threadId, CallAttendant, "呼叫服务员!!");
string result = threadHotel.EndInvoke(out threadId, asyncResult);
WriteLine(result);
asyncResult = threadHotel.BeginInvoke(out threadId, CallAttendant, "呼叫服务员!!");
result = threadHotel.EndInvoke(out threadId, asyncResult);
WriteLine(result);
Read();
}
private delegate string ThreadHotel(out int threadId);
private static void CallAttendant(IAsyncResult asyncResult)
{
WriteLine(asyncResult.AsyncState);
WriteLine($"我是厨师 {CurrentThread.ManagedThreadId} 号,饭做好了。");
}
private static string Cooking(out int threadId)
{
WriteLine($"当前厨师是否是 ThreadHotel 的员工 {CurrentThread.IsThreadPoolThread}");
WriteLine($"厨师编号:{CurrentThread.ManagedThreadId}");
WriteLine("开始做饭!!!!");
WriteLine("完成做饭");
threadId = CurrentThread.ManagedThreadId;
return $"厨师 {threadId} 做的饭!";
}
}
}
上面我们模拟了一个厨师做饭的过程。首先我们定义了一个委托 ThreadHotel ,接着调用 BeginInvoke 方法来运行委托。 BeginInvoke 方法接受一个回调函数,回调函数会在异步执行完成后被调用,并且我们传递了一个字符串到回调函数内(这个字符串是一个自定义状态,我们在这里不仅可以传递字符串还可以传递任何 object 类型的数据)。 BeginInvoke 将返回实现了 IAsyncResult 接口的对象,可用于检测异步调用的过程。当操作完成时 BeginInvoke 的回调函数会进入到线程池中等待空闲的线程调用。之后我们通过 EndInvoke 方法获取异步调用的结果。如果异步调用尚未完成,EndInvoke 将阻塞调用线程直到它完成。 EndInvoke 方法可以将异步操作中未处理的异常抛出到调用线程中,因此我们在使用异步时必须要调用 Begin 和 End 方法。
一、异步操作
当我们需要在线程池中加入异步操作时,通过 ThreadPool.QueueUserWorkItem 方法即可实现线程池异步操作。 QueueUserWorkItem 有两个重载,分别是 QueueUserWorkItem(WaitCallback) 和 QueueUserWorkItem(WaitCallback, Object) 。这两种重载都传入了一个要执行的方法,这个方法将加入到线程池的队列中,当有空闲的线程时,空闲线程将调用这个方法。第二个重载将需要执行的方法的必要参数传入了进来。下面我们依然通过一个简单的例子来看一下。
using System;
using System;
using System.Threading;
using static System.Console;
using static System.Threading.Thread;
namespace AsynchronousOperation
{
class Program
{
static void Main(string[] args)
{
WriteLine("服务员:您吃点什么");
//顾客点的餐
string[] dishes = new string[] { "醋溜白菜", "水煮肉片", "疙瘩汤" };
WriteLine($"顾客:我要吃 {string.Join("、",dishes)}");
//服务员下单
ThreadPool.QueueUserWorkItem(Cooking, dishes);
Sleep(2000);
//顾客加菜
string[] addVegetables = new string[] { "拍黄瓜", "白菜豆腐汤" };
WriteLine($"顾客:服务员再来份 {string.Join("、",addVegetables)}");
//服务员再次下单
ThreadPool.QueueUserWorkItem(Cooking, addVegetables);
//服务员错误下单
ThreadPool.QueueUserWorkItem(Cooking);
Read();
}
static void Cooking(object state)
{
int threadId = CurrentThread.ManagedThreadId;
if (state==null)
{
WriteLine($"{threadId} 号厨师:空菜单?WTF");
return;
}
string[] dishes = (string[])state;
WriteLine($"本店厨师:{threadId} 号开始炒菜");
for (int i = 0; i < dishes.Length; i++)
{
WriteLine($"厨师 {threadId} 号开始制作 {dishes[i]}");
Sleep(2000);
WriteLine($"厨师 {threadId} 号制作 {dishes[i]} 完成");
}
}
}
}
上述代码我们模拟了顾客点餐、厨师做饭和顾客加菜的过程。首先我们定义了 Cooking 方法来模拟厨师做菜,在方法中通过 Sleep 来模拟厨师做每一道菜的时间。之后我们在 Main 方法里通过 ThreadPool.QueueUserWorkItem 方法将顾客第一次点餐的内容传入 Cooking 中。如果存在空闲的厨师(线程),那么空闲的厨师开始就开始接单做饭。接着我们通过 Sleep 方法来暂停 2 秒,然后我们再次通过 ThreadPool.QueueUserWorkItem 方法将顾客所加的菜传入 Cooking 方法中。这时如果上一个做菜的厨师空闲下来了,那么它将接单继续做饭,反之由其他厨师接单做饭。从上面的代码中我们可以看出,虽然我们两次点餐之间暂停了 2 秒但是第一次点单的那个厨师还没有做完所有的饭,因此第二次点单后接单做饭的厨师是另一个厨师。当我们把两次点单的时间间隔变为 20 秒后,第一次点单和第二次点单的接单做饭的厨师都是同一个厨师了。前面的代码我们使用的是闭包机制,我们也可以使用传递 lambda 表达式的形式。如下代码:
ThreadPool.QueueUserWorkItem(state =>
{
int threadId = CurrentThread.ManagedThreadId;
string[] lambd_dishes = (string[])state;
WriteLine($"本店厨师:{threadId} 号开始炒菜");
for (int i = 0; i < lambd_dishes.Length; i++)
{
WriteLine($"厨师 {threadId} 号开始制作 {lambd_dishes[i]}");
Sleep(2000);
WriteLine($"厨师 {threadId} 号制作 {lambd_dishes[i]} 完成");
}
},dishes);
我们不推荐在线程池中使用 lambda 表达式的方式,首先这样会出现大量重复代码,其次代码一旦很多票很容易造成不易理解的问题。相对来说闭包更加灵活,允许我们传递多个具有静态类型的对象。
二、时间换空间
当我要创建并运行大量的线程时,如果通过 new Thread() 的方式创建,虽然运行耗时很短但是这些线程消耗了大量的操作系统资源。在一些计算密集型的项目中这样会造成整个系统运行缓慢,甚至是操作系统运行缓慢。这时我们可以牺牲时间来换取减少对操作系统资源的占用,这就是所谓的时间换空间。同样我们通过例子来看一下。
using System;
using System.Diagnostics;
using System.Threading;
using static System.Console;
using static System.Threading.Thread;
namespace TimeForSpace
{
class Program
{
static void Main(string[] args)
{
var sw = new Stopwatch();
sw.Start();
using (var cd = new CountdownEvent(200))
{
for (int i = 0; i < 200; i++)
{
Thread thread = new Thread(() =>
{
Sleep(200);
cd.Signal();
});
thread.Start();
}
cd.Wait();
}
sw.Stop();
WriteLine($"普通方式耗时 {sw.ElapsedMilliseconds} 毫秒");
sw.Reset();
sw.Start();
using (var cd = new CountdownEvent(200))
{
for (int i = 0; i < 200; i++)
{
ThreadPool.QueueUserWorkItem(_ =>
{
Sleep(200);
cd.Signal();
});
}
cd.Wait();
}
sw.Stop();
WriteLine($"线程池方式耗时 {sw.ElapsedMilliseconds} 毫秒");
Read();
}
}
}
首先我们以普通方式创建了 200 个线程,通过运行结果我们可以看出普通方式只需 4 秒多就完成了,但是我们通过资源管理器看到资源占用出现了明显的升高。接着我们通过线程池的方式创建并运行 200 个线程,这时运行所有线程需要 6 秒多,但是资源占用明显减少。虽然这种方式可以降低资源占用,但是并不是所有的项目都适合这种的方式,我们要根据项目情况来考虑使用。