1.背景
一直都想写一篇关于多线程的文章,总结一下这方面的知识,也为自己和有需要的人提供参考,不再赘述,开始吧,
现在计算机一般都是多核的,4核和8核的比较多件,用于线上计算或专用工作的计算机更是大概率“土豪”级别,如果不充分利用计算机的这一资源,那么将是一种浪费,尤其在现在行业竞争激烈,拼性能
的时代,充分利用有限的计算机资源显得尤为重要,试看一下你用visual studio编译项目时,自己电脑的cpu使用率会多高?没错,接近100%!你所用的开发工具都在充分利用多核资源,节省你开发编译时
的时间,当你在热修复一个问题时,当你在上线前的冲刺时,或者当你在平时的一次普通按F7键时,它都在尽量的高效率运行,尽量节省你的时间。作为开发者,你的用户也是如此,他们需要节省时间,
感受友好的软件使用体验,至少在特定的情况下,如果你的程序恰好提供这种服务,他们将比较舒缓,同时也更可能提升程序的竞争力。
c++和c#的一起总结下吧,正好也可以对比一下,加深相关知识的理解。
注意本文可能不会详尽的介绍所有线程相关的知识,但是会结合样例尽量充分的讲述多线程相关技术。
2.多线程技术
2.1.线程类
2.1.1.C++
c++11之后又thread类,同样一些第三方库中也有线程相关类的实现,如boost库,甚至还有一些第三方库实现了线程池(threadPool)类,我们这里只关注std中的thread类,
thread threadItem(work);
// other work there.
// Blocks until the associated thread completes.
threadItem.join();
2.1.2.C#
相对来说c#的.net frameworks中的相关类就多了不少,毕竟c#写起来很流畅,这是有原因的~
创建专有线程,通过Thread类,
t = new Thread(this.Run); t.Start();
由于线程的创建和销毁需要消耗一定的时间和内存,能不能尽量少的进行创建和销毁的过程,而又可以发挥多线程的优势?想更高效率的使用线程, 让线程持续的工作,做完一个任务,还会继续去执行另外的任务(如果有的话),那么可以使用线程池技术,C#.netframework已经实现了相关类,
ThreadPool.QueueUserWorkItem(ThreadProc);
更高级的封装,将线程池的概念继续进行封装,使用task,用户只需要知道task执行是异步方式就可以了(会使用线程池技术来实现),另外获取task的结果也很方便,甚至还可以给task发信号让它终止执行!是不是很方便?用户无须自己写复杂的实现,只需要调用接口就能达到目的,具体请参考Microsoft文档或《CLR Via C#》。
using System; using System.Threading.Tasks; public class Example { public static void Main() { var t = Task<int>.Run(() => { // Just loop. int max = 1000000; int ctr = 0; for (ctr = 0; ctr <= max; ctr++) { if (ctr == max / 2 && DateTime.Now.Hour <= 12) { ctr++; break; } } return ctr; }); Console.WriteLine("Finished {0:N0} iterations.", t.Result); } } // The example displays output like the following: // Finished 1,000,001 loop iterations.
var t = Task<int>.Factory.StartNew(() => { // Just loop. int max = 1000000; int ctr = 0; for (ctr = 0; ctr <= max; ctr++) { if (ctr == max / 2 && DateTime.Now.Hour <= 12) { ctr++; break; } } return ctr; }); Console.WriteLine("Finished {0:N0} iterations.", t.Result);
当然这些类中还有其他的方法,具体请参考MSDN文章(在VS中光标选中相应的类,按F1)、《CLR Via C#》(这本书很经典,大牛著作,会详细又不冗余的讲述.Net FrameWorks中的机制原理及当初为什么这样设计),
Task<TResult> 类
ThreadPool 类
已经有了这么高级的封装,还有其他的吗?有的,为了方便开发者使用多线程技术,充分的利用计算机的多核资源,.NetFrameWorks库还实现了一些常用操作的异步版本,如Parallel.ForEach,
Parallel.ForEach
尽情探索吧,
2.2.互斥锁
互斥锁主要用在多线程需要争夺抢占资源(数据)的场景,对资源(数据)进行加锁,避免多线程同时读写可能发生的不明确行为,使逻辑简单且在逻辑上减少出错的可能性。
这个概念也对应【资源同步】,和【线程同步】是不一样的概念,下文会讲到。
如果两个线程之间不需要任务数据共享,那么它们两个可以自由而独立的运行,这时不需要线程锁;但实际编程中很多多线程使用的场景都需要共享一些数据,如缓存数据,线程a执行某个任务时将key和结果数据存到缓存中,其他线程在执行相同类型的任务时需要去访问缓存数据,看是否已经计算过,如果计算过那直接返回就可以了(避免重复的计算过程),如果没有计算过,那么执行计算过程并将结果数据也写到缓存中,这种很常见的场景就涉及到了资源抢占的问题。
一般来说,多线程环境中对一份数据的访问有几种规则:
- 读和写是相互排斥的,即如果有线程正在读,那么其他线程不能写;同样如果有线程在写,那么其他线程不能同时读;
- 写和写也是互斥的,即同时只能有一个线程在读(想想为什么git上,两个人同时修改了某个地方的代码,最后合并时会出现冲突);
- 但读和读是不排斥的,即同时可以有多个线程在读;
接来来我们来看c++/c#中锁的使用,
2.2.1.C++
c++中使用锁也有多种方式,除了标准库,一些第三方库也实现了锁,有些库实现的既好用又高效,而足以影响标准库的后续版本;我们这里只看c++17中的,
mutex,可使用unique_lock来获取锁,离开unique_lock作用域时,它会自动的释放锁,当然可以直接调用mutex的方法,还有其他的方式如std::lock_guard,这里不再介绍,读者可自行查阅相关资料。
bool GetOrCreateObject(...) { // in this func, you can read or write, and acquire object unique_lock<mutex> uLcM(itemMutex); // here you can do something }
上述例子是将读和写封装在了一个方法中,因此只需要mutex即可;如果是将读和写放到了不同的方法中,那么下文中的shared_mutex更适用。
shared_mutx,可使用unique_lock、shared_lock来获取和释放锁,相对于mutex,其适用方位更广也更灵活,适用于将读和写操作分开的场景(多线程读写规则见上文),
bool TryToAcquireObject(...) { // in this func, you can try to read specific object shared_lock<shared_mutex> uLcM(itemMutex); // here you can do something } void WriteObjectToCache(object obj, ...) { // in this func, you can write specific object to cache unique_lock<shared_mutex> uLcM(itemMutex); // here you can do something }
此外,还有
- std::recursive_mutex ,允许同一线程使用recursive_mutext多次加锁,然后使用相同次数的解锁操作解锁。mutex多次加锁会造成死锁
- std::timed_mutex,在mutex上增加了时间的属性。增加了两个成员函数try_lock_for(),try_lock_until(),分别接收一个时间范围,再给定的时间内如果互斥量被锁主了,线程阻塞,超过时间,返回false。
- std::recursive_timed_mutex,增加递归和时间属性
可参考C++多线程编程,或MSDN文档,
2.2.2.C#
C#中的锁方式很多,可以直接对某个对象lock,也可以使用互斥量Mutex、Monitor等,
Mutex,参阅Microsoft官方文档示例如下,用法和c++的mutex没有本质的区别,
using System; using System.Threading; class Example { // Create a new Mutex. The creating thread does not own the mutex. private static Mutex mut = new Mutex(); private const int numIterations = 1; private const int numThreads = 3; static void Main() { // Create the threads that will use the protected resource. for(int i = 0; i < numThreads; i++) { Thread newThread = new Thread(new ThreadStart(ThreadProc)); newThread.Name = String.Format("Thread{0}", i + 1); newThread.Start(); } // The main thread exits, but the application continues to // run until all foreground threads have exited. } private static void ThreadProc() { for(int i = 0; i < numIterations; i++) { UseResource(); } } // This method represents a resource that must be synchronized // so that only one thread at a time can enter. private static void UseResource() { // Wait until it is safe to enter. Console.WriteLine("{0} is requesting the mutex", Thread.CurrentThread.Name); mut.WaitOne(); Console.WriteLine("{0} has entered the protected area", Thread.CurrentThread.Name); // Place code to access non-reentrant resources here. // Simulate some work. Thread.Sleep(500); Console.WriteLine("{0} is leaving the protected area", Thread.CurrentThread.Name); // Release the Mutex. mut.ReleaseMutex(); Console.WriteLine("{0} has released the mutex", Thread.CurrentThread.Name); } } // The example displays output like the following: // Thread1 is requesting the mutex // Thread2 is requesting the mutex // Thread1 has entered the protected area // Thread3 is requesting the mutex // Thread1 is leaving the protected area // Thread1 has released the mutex // Thread3 has entered the protected area // Thread3 is leaving the protected area // Thread3 has released the mutex // Thread2 has entered the protected area // Thread2 is leaving the protected area // Thread2 has released the mutex
lock,效果和Mutex一样,用法稍有区别,
object CreateObject(...) { lock(seqLock) { // here you can do something. } }
接下来看Monitor,其用法和C++的condition_variable很类似(见下文和文章结尾处的总结),好吧,Monitor结合了 获取/释放访问资源的锁(也即临界资源的同步)和线程同步的功能,其作用和提供的功能介于资源同步和线程同步之间。
换句话说就是,
- 可以用Monitor的Enter()、Exit()系列方法来达到对资源上锁的目的,如同Mutex、lock所做的那样;
- 同时在获取了访问权(拿到锁)之后,还可以调用Wait()来释放锁,并把当前线程放到该资源的等待队列中(这个应用场景可能是某个线程拿到了访问权,但发现其所需的其他条件还没有准备好),所以他就等待:释放锁并把自己放到资源的等待队列中,直到所需条件准备好:等待Pulse()或PulseAll(),之后再继续去排队尝试再去获取锁;这个过程很明显的是线程同步的场景。
// Define the lock object. var obj = new Object(); // Define the critical section. Monitor.Enter(obj); try { // Code to execute one thread at a time. } // catch blocks go here. finally { Monitor.Exit(obj); }
注意:Monitor.Enter()、Monitor.Exit()的对象对除string之外的引用类型是有效的,因为内部实现是需要装箱拆箱,值类型和string类型装箱拆箱之后是拷贝了另一个独立的副本,就导致Enter()和Exit()的对象实际不对应的错误了。
2.3.线程同步
资源同步(锁)和线程同步目的都是 通过同步信息来改变线程的执行情况,来达到逻辑合理,进而保证程序能够正常预期的进行。
和资源同步的 因保护资源访问而调整线程的状态(正常进行或等待)不同(从这个意义上来说这些相关线程做的功能都是一样的,比如上文提到的不同线程做相同的事情),线程同步是线程之间有依赖关系,比如线程b的执行依赖于线程a准备好相关数据。
C++和C#线程同步相关的技术和用法很类似,都是通过发信号类似的操作来达到目的的。
2.3.1.C++
condition_variable,condition_variable功能和用法和C#的Monitor类很类似,在获取锁后可以根据需要调用wait()来等待(此时会释放锁,并把当前线程放到该锁的等待队列中)直到收到信号,才可能去尝试获取锁,这里的可能是根据信号不同而可能或会,比如收到notify_all就一定会去尝试获取锁,notify_one时候是队列中的head去尝试获取锁,
#include <iostream> // std::cout #include <thread> // std::thread #include <mutex> // std::mutex, std::unique_lock #include <condition_variable> // std::condition_variable std::mutex mtx; // 全局互斥锁. std::condition_variable cv; // 全局条件变量. bool ready = false; // 全局标志位. void do_print_id(int id) { std::unique_lock <std::mutex> lck(mtx); while (!ready) // 如果标志位不为 true, 则等待... cv.wait(lck); // 当前线程被阻塞, 当全局标志位变为 true 之后, // 线程被唤醒, 继续往下执行打印线程编号id. std::cout << "thread " << id << '\n'; } void go() { std::unique_lock <std::mutex> lck(mtx); ready = true; // 设置全局标志位为 true. cv.notify_all(); // 唤醒所有线程. } int main() { std::thread threads[10]; // spawn 10 threads: for (int i = 0; i < 10; ++i) threads[i] = std::thread(do_print_id, i); std::cout << "10 threads ready to race...\n"; go(); // go! for (auto & th:threads) th.join(); return 0; }
输出如下,
10 threads ready to race... thread 1 thread 0 thread 2 thread 3 thread 4 thread 5 thread 6 thread 7 thread 8 thread 9
读者可自行对比condition_variable和C#的Monitor类的用法,加深理解。当然文章结尾处的总结也会总结说明。
condition_variable的wait()还有另一个重载,
void wait(unique_lock<mutex>& Lck, Predicate Pred);
相当于执行了下面的代码,
while(!Pred()) wait(Lck);
只有当Pred()返回false时才会去wait(),此时直到Pred()返回true并且接收到了信号,才会激活然后继续执行,用法示例如下,
#include <iostream> // std::cout #include <thread> // std::thread, std::this_thread::yield #include <mutex> // std::mutex, std::unique_lock #include <condition_variable> // std::condition_variable std::mutex mtx; std::condition_variable cv; int cargo = 0; bool shipment_available() { return cargo != 0; } // 消费者线程. void consume(int n) { for (int i = 0; i < n; ++i) { std::unique_lock <std::mutex> lck(mtx); cv.wait(lck, shipment_available); std::cout << cargo << '\n'; cargo = 0; } } int main() { std::thread consumer_thread(consume, 10); // 消费者线程. // 主线程为生产者线程, 生产 10 个物品. for (int i = 0; i < 10; ++i) { while (shipment_available()) std::this_thread::yield(); std::unique_lock <std::mutex> lck(mtx); cargo = i + 1; cv.notify_one(); } consumer_thread.join(); return 0; }
输出如下,
1 2 3 4 5 6 7 8 9 10
2.3.2.C#
又讲到了C#,pullllllllll~C#的.Net Frameworks简直实现和封装的太好了,比如C#的线程池技术,task类,还有接下来要讲的,好吧,我们继续~(写文章不容易呀,请不要吝啬你表达态度的机会,觉得好就点赞收藏吧~)
上文中已经提到一种线程同步的方式:使用Monitor类,其功能和用法与C++的condition_variable很类似,请参考上文,另外还有AutoResetEvent和ManulResetEvent这两种信号量机制,主要的方法是set()、reset()、waitone(),前两个是控制信号的发放,后一个是等待信号,适用于不同线程间的信号交流,即线程同步,
我们来看MSDN上的示例,
using System; using System.Threading; public class Example { // mre is used to block and release threads manually. It is // created in the unsignaled state. private static ManualResetEvent mre = new ManualResetEvent(false); static void Main() { Console.WriteLine("\nStart 3 named threads that block on a ManualResetEvent:\n"); for(int i = 0; i <= 2; i++) { Thread t = new Thread(ThreadProc); t.Name = "Thread_" + i; t.Start(); } Thread.Sleep(500); Console.WriteLine("\nWhen all three threads have started, press Enter to call Set()" + "\nto release all the threads.\n"); Console.ReadLine(); mre.Set(); Thread.Sleep(500); Console.WriteLine("\nWhen a ManualResetEvent is signaled, threads that call WaitOne()" + "\ndo not block. Press Enter to show this.\n"); Console.ReadLine(); for(int i = 3; i <= 4; i++) { Thread t = new Thread(ThreadProc); t.Name = "Thread_" + i; t.Start(); } Thread.Sleep(500); Console.WriteLine("\nPress Enter to call Reset(), so that threads once again block" + "\nwhen they call WaitOne().\n"); Console.ReadLine(); mre.Reset(); // Start a thread that waits on the ManualResetEvent. Thread t5 = new Thread(ThreadProc); t5.Name = "Thread_5"; t5.Start(); Thread.Sleep(500); Console.WriteLine("\nPress Enter to call Set() and conclude the demo."); Console.ReadLine(); mre.Set(); // If you run this example in Visual Studio, uncomment the following line: //Console.ReadLine(); } private static void ThreadProc() { string name = Thread.CurrentThread.Name; Console.WriteLine(name + " starts and calls mre.WaitOne()"); mre.WaitOne(); Console.WriteLine(name + " ends."); } } /* This example produces output similar to the following: Start 3 named threads that block on a ManualResetEvent: Thread_0 starts and calls mre.WaitOne() Thread_1 starts and calls mre.WaitOne() Thread_2 starts and calls mre.WaitOne() When all three threads have started, press Enter to call Set() to release all the threads. Thread_2 ends. Thread_0 ends. Thread_1 ends. When a ManualResetEvent is signaled, threads that call WaitOne() do not block. Press Enter to show this. Thread_3 starts and calls mre.WaitOne() Thread_3 ends. Thread_4 starts and calls mre.WaitOne() Thread_4 ends. Press Enter to call Reset(), so that threads once again block when they call WaitOne(). Thread_5 starts and calls mre.WaitOne() Press Enter to call Set() and conclude the demo. Thread_5 ends. */
AutoRestEvent用法和ManulRestEvent用法大同小异,其初始信号是否开启是不一样的,这里不再详细展开,读者可自行去搜索学习~
2.4.其他用法
当然上述讲到的只是一些基本的知识和操作,基于这些知识我们可以用在多种多样的多线程场景中,解决不同的问题,就像基本颜色只有7中,但使用这7中颜色可以表达千千万万的颜色,再比如几何引擎提供了一些基本的几何类和操作,上层应用可以基于这些接口开发功能丰富的建模软件。那么切回到多线程,比如使用c++/c#来并行的处理多个同类型任务,
2.4.1.C++
void CheckVisible() { // init something there // acquire task to do from task queue while (ThreadUtils::GetOneValidItem(XXX)) { // do task there } } void ThreadUtils::Run() { list<shared_ptr<future<void>>> lstFuture; for (int i = 0; i < mThreadCountMax; i++) { packaged_task<void()> task(&CheckVisible); auto futureItem = make_shared<std::future<void>>(); *futureItem = task.get_future(); lstFuture.push_back(futureItem); thread threadItem(move(task)); threadItem.detach(); } // main thread wait all child thread to end there. return WaitToGetResult(lstFuture); } void ThreadUtils::WaitToGetResult(const list<shared_ptr<future<void>>>& lstFuture) { for (auto itrItem = lstFuture.begin(); itrItem != lstFuture.end(); ++itrItem) { (*itrItem)->get(); } } bool ThreadUtils::GetOneValidItem(Data*& data) { unique_lock<mutex> uniqueLock(mMutex); if (mDatas.empty()) return false; data = mDatas.back(); mDatas.erase(--mDatas.end()); return true; }
2.4.2.C#
public void DoTasks() { var mres = new ManualResetEvent[10]; for (int i = 0; i < 10; ++i) { mres[i] = new ManualResetEvent(false); // do task there ThreadPool.QueueUserWorkItem(Excute, i); } WaitHandle.WaitAll(mres); }
3.参考
1、MSDN相关文章,一如既往的详细且不繁杂,容易理解;
如:ManualResetEvent Class、thread 类 等;
2、C++11并发编程-条件变量(condition_variable)详解
https://blog.csdn.net/lv0918_qian/article/details/81745723
备注:很详细,接近实战;
3、std::this_thread::yield()使用理解
https://blog.csdn.net/ldw614/article/details/79924587
备注:比较生动,容易理解;
4、C# ManualResetEvent 类的用法
https://blog.csdn.net/hmdong7/article/details/83055322
备注:信号,set reset waitone;
5、C# ManualResetEvent使用方法详解
https://www.jb51.net/article/114815.htm
6、C# ManualResetEvent用法
https://www.cnblogs.com/forever-Ys/p/11675953.html
备注:比较容易理解;
7、C++多线程编程
https://blog.csdn.net/u011808673/article/details/80811998
备注:比较全,有一定基础看会更好
8、What is __wchar_t (with the leading double underscores) and why am I getting errors about it?
https://devblogs.microsoft.com/oldnewthing/20161201-00/?p=94836
9、thread Class
https://docs.microsoft.com/en-us/cpp/standard-library/thread-class?view=msvc-160
10、condition_variable Class
https://docs.microsoft.com/en-us/cpp/standard-library/condition-variable-class?view=msvc-160
备注:结合上述文章理解thread.join作用、condition_variable Class作用会更好
4.总结
首先,自己应该多写写文章,如果之前写的多线程的文章多的话,就不用先在这样把技术知识都攒到一起写了,应该可持续的学习和输出,稳定的输出;
多线程相关的逻辑尽量不要太复杂,也不需要很复杂,写的接口/库是线程安全的就可以了;
总结一下condition_variable吧(Monitor也类似),
void wait(unique_lock<mutex>& Lck);
(1)干活的人拿到锁了,等待信号,发现还没发信号,那么他就在原地等待(阻塞)并让出锁,
(2)别人(有机会,并且可以)拿到锁,准备好东西,然后发信号说准备好了,
(3)干活的人再去拿锁,拿到后就开始干活;
(4)简单的两方场景,干活的人依赖另一个人预先做好东西,等另一个人做好了,他就拿到锁,然后专心的干活(期间其他人拿不到锁)
template <class Predicate>
void wait(unique_lock<mutex>& Lck, Predicate Pred);
(1)干活的人拿到锁了,发现指示标志还没下达(等待命令),然后就去wait,释放锁,
(2)如果期间有人发信号说可以开始干了,干活的人还是会去检查有没有下达指令,如果没下达,就继续wait;
(3)直到下达指令的人准备好了,下达了指令,并发信号说可以开始了,那么干活的人才会去拿锁,开始专心的干活;
(4)适用于复杂的关系场景;
比如:通知队伍集合,准备行动,队伍中的每个人准备好了(归队了),都会喊口号(发信号说准备好了),但并不意味着要开始行动,
必须等待指令下达,行动才能开始,如果这期间有人还没准备好,那么指令下达了,也要行动。其实下单指令就是综合判断要不要开始行动的,一般来说就是每个人都准备好了,额外的其他事情也预先准备好,
那么就可以开始了。
再比如:运动员赛跑,每个选手准备好了都会有表示(如果有问题会提出来,没问题就是好了),但是开始时间不一定到了(或者下指令的人正在擦口哨),一切以下指令的人为准,他准备好了,
并且发了信号说开始,那么才能开始,当然这时候不论是否有选手没准备好,比赛都会开始。当然一般来说大家都准备好了(靠下指令的人综合判断,不能瞎指挥,否则可能出现有选手有问题,
仍然下指令开始),所以下指令的人很重要,要综合判断!负好责任。
正如开篇讲到的那样,要充分利用计算机的多核资源是离不开多线程技术的,多线程的方面的技术和知识点以及各语言(如C++、C#)实现的相关类是不同的,并且是详细的,以满足各种场景的需要,因此一篇或几篇文章是很难覆盖到方方面面的,但本文尽可能的将基础知识和C++/C#语言实现的多线程相关类和接口介绍到,用户可结合本文和其他文章中的知识来应用到自己的工作中,解决类型各样的问题。
记录总结下来,供需要的人参考~POLLLLLLLLLLLLLLLLL~