C#多线程
2021-02-24 18:06 阿诚de窝 阅读(896) 评论(0) 编辑 收藏 举报C#多线程简单示例
Thread类构造函数可以传入一个委托,作为线程调用的方法。
1 using System; 2 using System.Threading; 3 4 namespace Test 5 { 6 public class Thread1 7 { 8 public static void ThreadFunc1() 9 { 10 while (true) 11 { 12 Console.WriteLine("Thread 1!"); 13 Thread.Sleep(1000); 14 } 15 } 16 17 private int num = 5; 18 19 public Thread1() 20 { 21 // 使用静态方法作为线程调用的方法,不带参数 22 Thread thread1 = new Thread(ThreadFunc1); 23 thread1.Start(); 24 25 // 使用成员方法作为线程调用的方法,可带一个 object 类型的参数 26 Thread thread2 = new Thread(ThreadFunc2); 27 thread2.Start(2); 28 } 29 30 private void ThreadFunc2(object obj) 31 { 32 int count = num * (int)obj; 33 while (count > 0) 34 { 35 Console.WriteLine("Thread 2!"); 36 Thread.Sleep(1000); 37 --count; 38 } 39 } 40 } 41 }
Thread类的第二个参数可以控制堆栈大小,堆栈大小的简介如下:
每个线程独立拥有一个可配置大小的堆栈,一个线程内所有函数使用到的堆栈都依赖于这个栈,如果太多的变量、参数需要使用栈,则可能导致栈溢出。目前基础平台子系统通过配置环境变量,将默认堆栈大小设置为128K,可以减少这个问题的出现,但业务系统在编码时仍然 需要注意栈的使用,避免出现问题。
包括:
1、不要在函数内部定义过大的局部变量,如过大的结构体变量,联合变量,过大的字符串,数组等;
2、函数调用的深度也需要注意,如果函数 A 调用 B, B 再调用 C,而A/B/C每个函数定义了 10 K的局部变量,则总的栈空间需求将超过 30K;
3、不要直接将大的结构变量通过函数参数传递,这样也会消耗栈空间,可以通过指针或者引用的方式传递;
4、建议每个函数内部定义的变量大小控制在4-8K以下;
5、如果在运行中 COREDUMP,并且通过 GDB 的 WHERE 命令时看到刚进入某个函数就报错,连函数内的第一条调试语句都无法指向,则基本可以认为是栈空间不够导致的,可以尝试将栈空间配置大一点,如果问题不再出现,则可以确定问题。这时需要按照前面几点的要求修改代码,减少栈的使用。
前台线程和后台线程
所有前台线程关闭后,还有后台线程在运行的话,后台线程会全部关闭。
主线程和通过Thread构造函数创建的线程默认都是前台线程,线程池获取的则默认是后台线程,通过 IsBackground 属性可以设置和获取当前线程是前台线程还是后台线程。
执行优先级
Priority属性(ThreadPriority枚举)可以控制线程执行的优先级,高优先级的线程会优先执行。
同步
当多个线程同时对一个数据进行修改时,就会因为无法控制其访问顺序导致的无法预知的错误,我们看看下面的代码:
1 using System.Collections.Generic; 2 using System; 3 using System.Threading; 4 5 namespace Test 6 { 7 public class Thread2 8 { 9 private List<int> _nums; 10 11 public Thread2() 12 { 13 _nums = new List<int>(); 14 15 Thread thread1 = new Thread(ThreadFunc1); 16 thread1.Start(); 17 18 Thread thread2 = new Thread(ThreadFunc2); 19 thread2.Start(); 20 21 Console.WriteLine("线程已启动"); 22 23 Thread.Sleep(3000); 24 25 string str = ""; 26 foreach (var item in _nums) 27 { 28 str += item + ", "; 29 } 30 Console.WriteLine(str); 31 } 32 33 private void ThreadFunc1() 34 { 35 for (int i = 0; i < 10; i++) 36 { 37 AddNum(i); 38 } 39 } 40 41 private void ThreadFunc2() 42 { 43 for (int i = 10; i < 20; i++) 44 { 45 AddNum(i); 46 } 47 } 48 49 private void AddNum(int num) 50 { 51 _nums.Add(num); 52 } 53 } 54 }
输出如下:
0, 1, 2, 3, 14, 5, 6, 7, 8, 16, 17, 18, 19,
我们发现,由于可能同时调用AddNum方法,会导致_nums中的顺序和数量都出现问题。
可以通过给AddNum方法加锁来解决两个线程同时访问同一块数据,加了lock代码块的代码,可以保证同一时刻只有一个线程会对其进行访问,如下:
1 using System.Collections.Generic; 2 using System; 3 using System.Threading; 4 5 namespace Test 6 { 7 public class Thread2 8 { 9 private List<int> _nums; 10 11 public Thread2() 12 { 13 _nums = new List<int>(); 14 15 Thread thread1 = new Thread(ThreadFunc1); 16 thread1.Start(); 17 18 Thread thread2 = new Thread(ThreadFunc2); 19 thread2.Start(); 20 21 Console.WriteLine("线程已启动"); 22 23 Thread.Sleep(3000); 24 25 string str = ""; 26 foreach (var item in _nums) 27 { 28 str += item + ", "; 29 } 30 Console.WriteLine(str); 31 } 32 33 private void ThreadFunc1() 34 { 35 for (int i = 0; i < 10; i++) 36 { 37 AddNum(i); 38 } 39 } 40 41 private void ThreadFunc2() 42 { 43 for (int i = 10; i < 20; i++) 44 { 45 AddNum(i); 46 } 47 } 48 49 private void AddNum(int num) 50 { 51 lock (this) 52 { 53 _nums.Add(num); 54 } 55 } 56 } 57 }
看起来输出正常了,但是其实是每个线程的代码执行速度很快,所以看不出来线程的切换,如下:
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
下面我们在添加数字的地方加入一段比较耗时的方法,就会触发线程的切换了:
1 using System.Collections.Generic; 2 using System; 3 using System.Threading; 4 5 namespace Test 6 { 7 public class Thread2 8 { 9 private List<int> _nums; 10 11 public Thread2() 12 { 13 _nums = new List<int>(); 14 15 Thread thread1 = new Thread(ThreadFunc1); 16 thread1.Start(); 17 18 Thread thread2 = new Thread(ThreadFunc2); 19 thread2.Start(); 20 21 Console.WriteLine("线程已启动"); 22 23 Thread.Sleep(3000); 24 25 string str = ""; 26 foreach (var item in _nums) 27 { 28 str += item + ", "; 29 } 30 Console.WriteLine(str); 31 } 32 33 private void ThreadFunc1() 34 { 35 for (int i = 0; i < 10; i++) 36 { 37 AddNum(i); 38 } 39 } 40 41 private void ThreadFunc2() 42 { 43 for (int i = 10; i < 20; i++) 44 { 45 AddNum(i); 46 } 47 } 48 49 private void AddNum(int num) 50 { 51 lock (this) 52 { 53 _nums.Add(num); 54 largeComputationalCost(); 55 } 56 } 57 58 private void largeComputationalCost() 59 { 60 for (int i = 0; i < 10000000; i++) 61 { 62 } 63 } 64 } 65 }
可以从结果看出来,数字的插入顺序是乱序的:
0, 1, 10, 2, 3, 11, 12, 4, 5, 13, 14, 15, 6, 7, 8, 16, 9, 17, 18, 19,
lock
被lock标记的代码块,会被加锁,指定同一时刻,只有一个线程可以执行代码块中的代码,需要注意的是,lock可以带一个参数,该参数用于标记代码块的加锁状态。
下面我们简单的理解一下加锁参数的用法:
1 lock (this) 2 { 3 // ... 4 }
- 代码运行到lock时,会先判断下this对象是否已经被标记已经被某个线程运行(注意只针对lock当前的代码块);
- 如果没有任何线程在执行该代码块,则当前线程开始运行,并且标记this对象已经被当前线程运行;
- 如果正在某线程运行中,则阻塞等待那个线程执行完毕,其它线程执行完毕后,则当前线程开始运行,并且标记this对象已经被当前线程运行;
- 当前线程代码块执行完毕,标记this对象没有被任何线程运行;
对lock的参数加深理解
1. lock参数只能是引用类型,如果是值类型会怎样,我们看下面的例子:
1 int a = 1; 2 lock (a) 3 { 4 // ... 5 }
因为每次运行到这里,都会是一个新的值类型a,所以其它的线程给这个值类型a打了加锁标记后,下一个线程运行到这里会发现值类型a仍然是没有加锁的,lock代码块就变得毫无意义,多个线程仍然可以同时访问。
2. 大部分的情况下,lock参数都是使用的this:
当然这是因为,大部分情况下,我们多线程操作的都是当前对象实例的成员变量,多个对象的实例相互之间不需要加锁。
我们也可以传递其它的引用实例来打加锁标记,但是需要注意只有相同的引用,才会保证只有一个线程访问lock代码块,我们看看下面比较极端的情况:
1 MyClass a = new MyClass(); 2 lock (a) 3 { 4 // ... 5 }
这种情况和使用值类型一样,因为每次执行都会产生一个新对象,所以加lock代码是没有意义的,多个线程仍然可以同时访问。
Monitor
lock代码块可以看做是Monitor的语法糖,在IL代码中lock会被翻译成Monitor,也就是Monitor.Enter(obj)和Monitor.Exit(obj),如下:
1 lock (this) 2 { 3 // ... 4 } 5 // 等同于下面这样 6 try 7 { 8 Monitor.Enter(this); 9 // ... 10 } 11 finally 12 { 13 Monitor.Exit(this); 14 }
Monitor还额外提供了一些功能:
1. Monitor.TryEnter(obj, timespan),超过timespan的时间之后,就不执行这段代码了,而lock会一直等待从而出现死锁。
2. Monitor.Wait()、Monitor.Pulse()和Monitor.PulseAll(),要弄清楚这3个方法的含义,需要先理解lock的下面的流程:
对于同一个被lock的对象,会有下面3个属性:
- 拥有锁的线程:当前正在执行的线程;
- 就绪队列(ready queue):执行了lock、Monitor.Enter或Monitor.TryEnter的线程会放入该队列中,当拥有锁的线程释放锁之后,会让该队列中的下一个线程拥有锁并执行;
- 等待队列(wait queue):放入该队列中的线程,不会在当拥有锁的线程释放锁之后让下一个执行,也不会加入到就绪队列中,会等待明确的指令来确定怎么处理队列中的线程;
明白了上面的3个属性后,就可以具体看这3个方法了:
- Monitor.Wait:将当前拥有锁的线程释放锁且阻塞,并将当前的线程添加到等待队列中;
- Monitor.Pulse:将等待队列中一个线程移到就绪队列中;
- Monitor.PulseAll:将等待队列中的所有线程都移到就绪队列中;
其它3种同步方式
下面说的3种同步方式都属于内核对象,利用内核对象进行进程或线程之间的同步,线程必须要在用户模式和内核模式间切换,所以一般效率较lock会低一些。
不同于Monitor,这3种同步方法都可以在任意的地方对线程进行等待或者运行的控制。
EventWaitHandler
EventWaitHandle 类允许线程通过发信号互相通信。通常,一个或多个线程在 EventWaitHandle 上阻止,直到一个未阻止的线程调用 Set 方法,以释放一个或多个被阻止的线程。
Semaphore
类似互斥锁,但它可以允许多个线程同时访问一个共享资源,通过使用一个计数器来控制对共享资源的访问,如果计数器大于0,就允许访问,如果等于0,就拒绝访问。计数器累计的是“许可证”的数目,为了访问某个资源。线程必须从信号量获取一个许可证。
Mutex
Mutex类似于一个接力棒,拿到接力棒的线程才可以开始跑,当然接力棒一次只属于一个线程(Thread Affinity),如果这个线程不释放接力棒(Mutex.ReleaseMutex),那么没办法,其他所有需要接力棒运行的线程都知道能等着看热闹。
死锁
当一个或多个进程等待系统资源,而资源又被进程本身或其它进程占用时,就形成了死锁。总的来说,就是两个线程,都需要获取对方锁占有的锁,才能够接着往下执行,但是这两个线程互不相让,你等我先释放,我也等你先释放,但谁都不肯先放,就一直在这僵持住了。
我们看一个简单的示例:
1 using System; 2 using System.Threading; 3 4 namespace Test 5 { 6 public class Thread3 7 { 8 private Object obj1 = new object(); 9 private Object obj2 = new object(); 10 11 public Thread3() 12 { 13 Thread thread1 = new Thread(ThreadFunc1); 14 thread1.Start(); 15 16 Thread thread2 = new Thread(ThreadFunc2); 17 thread2.Start(); 18 } 19 20 private void ThreadFunc1() 21 { 22 lock (obj1) 23 { 24 Console.WriteLine("开始执行方法一"); 25 Thread.Sleep(1000); 26 lock (obj2) 27 { 28 Console.WriteLine("方法一执行完毕"); 29 } 30 } 31 } 32 33 private void ThreadFunc2() 34 { 35 lock (obj2) 36 { 37 Console.WriteLine("开始执行方法二"); 38 Thread.Sleep(1000); 39 lock (obj1) 40 { 41 Console.WriteLine("方法二执行完毕"); 42 } 43 } 44 } 45 } 46 }
输出如下:
开始执行方法一
开始执行方法二
避免死锁可以有下面几个方法:
- 应该尽量避免大量嵌套的锁的使用;
- 可以使用锁的超时机制来避免对资源的长时间占用;
- 通过逻辑上的检查来避免死锁;
线程池
线程池(ThreadPool)有下面几个特点:
- 线程池中所有线程都是后台线程,如果进程的所有前台线程都结束了,所有的后台线程就会停止。不能把入池的线程改为前台线程。
- 不能给入池的线程设置优先级或名称。
- 入池的线程只能用于时间较短的任务。如果线程要一直运行(如Word的拼写检查器线程),就应使用Thread类创建一个线程。
- 一个进程有且只能管理一个线程池。
- 当进程启动时,线程池并不会自动创建。当第一次将回调方法排入队列(比如调用ThreadPool.QueueUserWorkItem方法)时才会创建线程池。
- 在对一个工作项进行排队之后将无法取消它。
- 线程池中线程在完成任务后并不会自动销毁,它会以挂起的状态返回线程池,如果应用程序再次向线程池发出请求,那么这个挂起的线程将激活并执行任务,而不会创建新线程,这将节约了很多开销。
- 只有线程达到最大线程数量,系统才会以一定的算法销毁回收线程。
不适合使用线程池的情形包括:
- 如果需要使一个任务具有特定的优先级。
- 如果具有可能会长时间运行(并因此阻塞其他任务)的任务。
- 如果需要将线程放置到单线程单元中(线程池中的线程均处于多线程单元中)。
- 如果需要用永久标识来标识和控制线程,比如想使用专用线程来中止该线程,将其挂起或按名称发现它。
- 如果您需要运行与用户界面交互的后台线程,.NET Framework 2.0 版提供了 BackgroundWorker 组件,该组件可以使用事件与用户界面线程的跨线程封送进行通信。
线程池的优势:
- 可以避免创建和销毁消除的开支,从而可以实现更好的性能和系统稳定性。
- 把线程交给系统进行管理,程序员不需要费力于线程管理,可以集中精力处理应用程序任务。
我们看一个简单的示例:
1 using System; 2 using System.Threading; 3 4 namespace Test 5 { 6 public class Thread4 7 { 8 public Thread4() 9 { 10 ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadFunc), 10); 11 ThreadPool.QueueUserWorkItem(new WaitCallback(ThreadFunc), 15); 12 13 // 避免程序退出 14 Thread.Sleep(5000); 15 } 16 17 private void ThreadFunc(object o) 18 { 19 for (int i = 0; i < (int)o; i++) 20 { 21 Thread.Sleep(100); 22 } 23 Console.WriteLine("线程已执行完毕 " + o); 24 } 25 } 26 }
输出如下:
线程已执行完毕 10 线程已执行完毕 15
Task
ThreadPool存在一些使用上的不便,比如:
- ThreadPool不支持线程的取消、完成、失败通知等交互性操作;
- ThreadPool不支持线程执行的先后次序;
而Task在线程池的基础上进行了优化,并提供了更多的API。
我们看一个简单的例子:
1 using System; 2 using System.Threading; 3 using System.Threading.Tasks; 4 5 namespace Test 6 { 7 public class Thread5 8 { 9 public Thread5() 10 { 11 Task t = new Task(() => 12 { 13 Console.WriteLine("任务开始工作……"); 14 // 模拟工作过程 15 Thread.Sleep(5000); 16 }); 17 t.Start(); 18 t.ContinueWith((task) => 19 { 20 Console.WriteLine("任务完成,完成时候的状态为:"); 21 Console.WriteLine("IsCanceled={0}\tIsCompleted={1}\tIsFaulted={2}", task.IsCanceled, task.IsCompleted, task.IsFaulted); 22 }); 23 24 // 避免程序退出 25 Thread.Sleep(6000); 26 } 27 } 28 }
输出如下:
任务开始工作……
任务完成,完成时候的状态为:
IsCanceled=False IsCompleted=True IsFaulted=False
Parallel
Parallel类提供了数据和任务的并行性;
我们主要看下其For方法的使用,类似于C#的for循环语句,也是多次执行一个任务。使用Paraller.For()方法,可以并行运行迭代,迭代的顺序是乱序的。
我们直接看一个例子:
1 using System; 2 using System.Threading; 3 using System.Threading.Tasks; 4 5 namespace Test 6 { 7 public class Thread6 8 { 9 public Thread6() 10 { 11 ParallelLoopResult result = Parallel.For(0, 10, new ParallelOptions() { MaxDegreeOfParallelism = 10 }, i => 12 { 13 Console.WriteLine("迭代次数:{0}, 任务ID:{1}, 线程ID:{2}", i, Task.CurrentId, Thread.CurrentThread.ManagedThreadId); 14 Thread.Sleep(10); 15 }); 16 Console.WriteLine("是否完成:{0}", result.IsCompleted); 17 } 18 } 19 }
输出如下:
迭代次数:2, 任务ID:2, 线程ID:6 迭代次数:0, 任务ID:5, 线程ID:1 迭代次数:1, 任务ID:1, 线程ID:4 迭代次数:3, 任务ID:3, 线程ID:5 迭代次数:4, 任务ID:4, 线程ID:7 迭代次数:7, 任务ID:5, 线程ID:1 迭代次数:6, 任务ID:4, 线程ID:7 迭代次数:5, 任务ID:1, 线程ID:4 迭代次数:8, 任务ID:2, 线程ID:6 迭代次数:9, 任务ID:3, 线程ID:5 是否完成:True
Unity中使用多线程
和C#中使用完全一致,需要注意的是,子线程不能操作和访问Unity的任何对象,需要通过发送消息到主线程来实现控制。