代码改变世界

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 }
View Code

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 }
View Code

输出如下:

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 }
View Code

看起来输出正常了,但是其实是每个线程的代码执行速度很快,所以看不出来线程的切换,如下:

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 }
View Code

可以从结果看出来,数字的插入顺序是乱序的:

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 }
View Code

输出如下:

开始执行方法一
开始执行方法二

避免死锁可以有下面几个方法:

  1. 应该尽量避免大量嵌套的锁的使用;
  2. 可以使用锁的超时机制来避免对资源的长时间占用;
  3. 通过逻辑上的检查来避免死锁;

线程池

线程池(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 }
View Code

输出如下:

线程已执行完毕 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 }
View Code

输出如下:

任务开始工作……
任务完成,完成时候的状态为:
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 }
View Code

输出如下:

迭代次数: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的任何对象,需要通过发送消息到主线程来实现控制。