C#异步多线程——ThreadPool线程池
C#实现异步多线程的方式有多种,以下总结的是ThreadPool的用法。
线程池的特点
线程池受CLR管理,线程的生命周期,任务调度等细节都不需要我们操心了,我们只需要专注于任务实现,使用ThreadPool提供的静态方法把我们的任务添加到任务队列中,剩下的请放心交给CLR。
- 线程重用:和其它池化资源有着共同特点,当创建某个对象,创建和销毁代价太高得情况,而且这个对象又可以反复利用,往往我们可以准备一个容器,这个容器可以保存一批这样的对象,如果需要使用这个对象,不需要创建,可以去池中去拿,拿到就用,用完再放回池中,这样就减少了创建和销毁的开销,性能可以得到提升。
- ThreadPool使用简单,没有直接操作线程的API,例如暂停,恢复,销毁线程,设置前台后台线程(默认都是后台线程),设置优先级等。
- 有线程数限制,无法无限使用。
环境准备
Visual Studio 创建测试项目,我使用的是Windows Forms App(.NET Framework)模板创建。简单的添加几个测试按钮,在Click事件中进行简单测试。并且准备一段模拟非常耗时操作的代码,例如:
private void DoSomethingLong(string name)
{
Console.WriteLine($"********** DoSomethingLong Start {name} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
long lResult = 0;
//2000000000? 能看出明显延时就好
for (int i = 0; i < 2000000000; i++)
{
lResult += i;
}
Console.WriteLine($"********** DoSomethingLong End {name} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
}
认识下ThreadPool
两种常用启动方式
- WaitCallback:本质是一个委托,没有返回值,有一个object类型的参数。如果任务不需要传入参数可以使用:
public static bool QueueUserWorkItem(WaitCallback callBack)
; - 如果需要传入参数可以使用另外一个重载方法,传入的object类型参数会自动传入到WaitCallback委托中的object参数中:
public static bool QueueUserWorkItem(WaitCallback callBack, object state)
;
private void BtnThreadPool_Click(object sender, EventArgs e)
{
Console.WriteLine("");
Console.WriteLine($"********** BtnThreadPool_Click Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
WaitCallback waitCallbackWithoutParam = state => Console.WriteLine($"waitCallbackWithoutParam {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
ThreadPool.QueueUserWorkItem(waitCallbackWithoutParam);
WaitCallback waitCallbackWithParam = state => Console.WriteLine($"waitCallbackWithParam state={state} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
ThreadPool.QueueUserWorkItem(waitCallbackWithParam, "ParameterizedThreadPool");
Console.WriteLine($"********** BtnThreadPool_Click End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
}
输出结果:
设置&获取线程数
- SetMaxThreads:设置最大线程数:
- SetMinThreads:设置最小线程数:
- GetMaxThreads:获取最大线程数:
- GetMinThreads:获取最小线程数:
private void BtnThreadPoolInfo_Click(object sender, EventArgs e)
{
{
//默认线程数
ThreadPool.GetMaxThreads(out int workerThreadsMax, out int completionPortThreadsMax);
ThreadPool.GetMinThreads(out int workerThreadsMin, out int completionPortThreadsMin);
Console.WriteLine($"默认线程数:workerThreadsMax={workerThreadsMax} workerThreadsMin={workerThreadsMin}");
}
{
//设置线程数
ThreadPool.SetMaxThreads(16, 16); //设置最大线程数
ThreadPool.SetMinThreads(12, 12); //设置最小线程数
ThreadPool.GetMaxThreads(out int workerThreadsMax, out int completionPortThreadsMax);
ThreadPool.GetMinThreads(out int workerThreadsMin, out int completionPortThreadsMin);
Console.WriteLine($"设置线程数:workerThreadsMax={workerThreadsMax} workerThreadsMin={workerThreadsMin}");
}
}
输出结果:
我用的电脑是6核12线程的,这个分配是不是很合理?所以CLR是很“聪明”的。如果没有什么极端的需求通常我们不需要自作聪明的去设置他们;
线程等待
ThreadPool没有提供等待线程完成的API,可以使用ManualResetEvent:
private void BtnThreadPoolWait_Click(object sender, EventArgs e)
{
Console.WriteLine("");
Console.WriteLine($"********** BtnThreadPoolWait_Click Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
ThreadPool.QueueUserWorkItem(state =>
{
this.DoSomethingLong("BtnThreadPoolWait_Click");
manualResetEvent.Set();
});
for (int i = 0; i < 1000000000; i++)
{
}
Console.WriteLine($"********** 主线程可以先干点别的 {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
manualResetEvent.WaitOne();
Console.WriteLine($"********** BtnThreadPoolWait_Click End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
}
输出结果:
为什么死锁了?
看下下面使用线程池发生死锁的例子:
private void BtnThreadPoolDeadLock_Click(object sender, EventArgs e)
{
Console.WriteLine("");
Console.WriteLine($"********** BtnThreadPoolDeadLock_Click Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
ThreadPool.SetMaxThreads(16, 16);
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
for (int i = 0; i < 20; i++)
{
int k = i;
ThreadPool.QueueUserWorkItem(state =>
{
Console.WriteLine($"k={k} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
if (k < 18)
{
manualResetEvent.WaitOne();
}
else
{
manualResetEvent.Set();
}
});
}
if (manualResetEvent.WaitOne())
{
Console.WriteLine("没有死锁");
}
Console.WriteLine($"********** BtnThreadPoolDeadLock_Click End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
}
输出结果:
- 程序开始最大线程数被设置为16,线程池中所有线程都在阻塞等待信号,导致发生死锁,程序已经卡死,因为没有线程可用了,发送信号的任务无法执行。
- 一般不要阻塞线程池的线程。
- 不要把最大线程数设置的很小,使用默认即可。
如何回调
上一篇文章我们讲委托的异步调用时,提到BeginInvoke可以传入回调,在线程执行完后自动执行这个回调,可是ThreadPool并没有提供传入回调的API,我们可以自己动手去封装一个,只要启动线程时先完成线程任务委托的调用,再执行回调就可以了,例如:
private void ThreadPoolWithCallback(Action act, Action callback)
{
ThreadPool.QueueUserWorkItem(state =>
{
act.Invoke();
callback.Invoke();
});
}
private void BtnThreadPoolWithCallback_Click(object sender, EventArgs e)
{
this.ThreadPoolWithCallback(() =>
{
Console.WriteLine($"这里是Action Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
for (int i = 0; i < 2000000000; i++)
{
}
Console.WriteLine($"这里是Action End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
}, () =>
{
Console.WriteLine($"这里是Callback Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
for (int i = 0; i < 2000000000; i++)
{
}
Console.WriteLine($"这里是Callback End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
});
}
输出结果:
如何获取返回值
上一篇文章我们讲委托的异步调用时,提到EndInvoke可以等待线程结束并获取返回值,可是ThreadPool没有提供获取线程返回值的API,传入的委托都是无返回值的,我们可以自己封装,先看下有问题的封装:
private T ThreadPoolWithReturn<T>(Func<T> func)
{
T t = default(T);
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
ThreadPool.QueueUserWorkItem(state =>
{
t = func.Invoke();
manualResetEvent.Set();
});
manualResetEvent.WaitOne();
return t;
}
private void BtnThreadPoolWithReturn_Click(object sender, EventArgs e)
{
int result = this.ThreadPoolWithReturn<int>(() =>
{
Console.WriteLine($"这里是func Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
for (int i = 0; i < 2000000000; i++)
{
}
Console.WriteLine($"这里是func End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
return DateTime.Now.Millisecond;
});
Console.WriteLine($"result={result} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
}
输出结果:
应该看出来虽然拿到了结果,但仔细看这个异步是“假异步”,线程刚启动就开始阻塞,等待线程拿到结果,这不和同步一样了吗?这里好像有点矛盾,又要结果,又不想阻塞,是不可能的,要结果就要等计算完成,但这个等待的时机我们可以好好斟酌一下,没必要子线程刚启动,主线程就阻塞等待,我们可以在子线程启动后,主线程先干点别的,等主线程真正要拿结果的时候再等待。那要如何实现?可以在子线程启动后返回一个委托,等需要拿结果的时候我们再调用这个委托。
private Func<T> ThreadPoolWithReturn<T>(Func<T> func)
{
T t = default(T);
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
ThreadPool.QueueUserWorkItem(state =>
{
t = func.Invoke();
manualResetEvent.Set();
});
//返回委托在这里可以立即结束不需要阻塞,只有要结果的时候才会等待完成
//因为这个委托中持有上下文环境比如 manualResetEvent 比如t,所以外部调用委托的时候可以拿到这些对象
return () =>
{
manualResetEvent.WaitOne();
return t;
};
}
private void BtnThreadPoolWithReturn_Click(object sender, EventArgs e)
{
Console.WriteLine("");
Console.WriteLine($"********** BtnThreadPoolWithReturn_Click Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
Func<int> func = this.ThreadPoolWithReturn<int>(() =>
{
Console.WriteLine($"这里是func Start {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
for (int i = 0; i < 2000000000; i++)
{
}
Console.WriteLine($"这里是func End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
return DateTime.Now.Millisecond;
});
//假如拿结果前做了一个耗时的计算
for (int i = 0; i < 1000000000; i++)
{
}
Console.WriteLine($"主线程拿结果前可以在这里干点别的... {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
int result = func.Invoke();
Console.WriteLine($"result={result} {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy - MM - dd HH: mm:ss.fff")}");
Console.WriteLine($"********** BtnThreadPoolWithReturn_Click End {Thread.CurrentThread.ManagedThreadId.ToString("00")} {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}");
}
输出结果:
- 返回委托并不会阻塞,只有在调用这个委托才会真正等待结果。
- 拿结果前主线程可以和子线程可以并发运行。