第8章:多线程


本章目标

  1. 理解并熟练使用多线程

本章内容

基本概念

进程

当一个程序开始运行时,它就是一个进程,进程包括运行中的程序和程序所使用到的内存和系统资源。 而一个进程又是由多个线程所组成的。

线程

线程是程序中的一个执行流,每个线程都有自己的专有寄存器(栈指针、程序计数器等),但代码区是共享的,即不同的线程可以执行同样的函数。

句柄

句柄是Windows系统中对象或实例的标识,这些对象包括模块、应用程序实例、窗口、控制、位图、GDI对象、资源、文件等。

多线程-Thread

Thread类

Thread类是是控制线程的基础类,位于System.Threading命名空间下,具有4个重载的构造函数:

名称 说明
Thread(ThreadStart start) 初始化Thread类的实例,要执行的方法是无参的。
Thread(ThreadStart start, int maxStackSize) 初始化Thread类的实例,指定线程的最大堆栈大小。
Thread(ParameterizedThreadStart start) 初始化Thread类的实例,要执行的方法是有参的。
Thread(ParameterizedThreadStart start, int maxStackSize) 初始化Thread类的实例,指定线程的最大堆栈大小。

创建多线程

1、编写线程所要执行的方法

2、实例化Thread类,并传入一个指向线程所要执行方法的委托。(这时线程已经产生,但还没有运行)

3、调用Thread实例的Start方法,标记该线程可以被CPU执行了,但具体执行时间由CPU决定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void Fun2()
{

//创建线程(无参)
Thread th1 = new Thread(new ThreadStart(Test1));
Thread th2 = new Thread(() => { Console.WriteLine("调用了线程2"); });

//创建线程(有参)
Thread th3 = new Thread(new ParameterizedThreadStart(Test2));
Thread th4 = new Thread((object obj) => { Console.WriteLine(obj.ToString()); });

//启动线程
//th1.Start();
//th2.Start();
th3.Start("hello");
th4.Start("你好");

}

static void Test1()
{
Console.WriteLine("调用了线程1");
}

static void Test2(object str)
{
Console.WriteLine(str.ToString());
}

线程的常用属性

属性 说明
CurrentContext 获取线程正在其中执行的当前上下文
CurrentThread 获取当前正在运行的线程
ExecutionContext 当前线程的各种上下文的信息
IsAlive 是否是执行状态
IsBackground 是否是后台线程
Name 线程名称
ManagedThreadId 线程ID
IsThreadPoolThread 是否属于托管线程池
Priority 线程优先级
ThreadState 线程状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static void Fun1()
{
//获取当前正在运行的线程
Thread th = Thread.CurrentThread;
th.Name = "MainThread";

Console.WriteLine("线程名:" + th.Name);
Console.WriteLine("线程ID:" + th.ManagedThreadId);
Console.WriteLine("是否在运行:" + th.IsAlive);
Console.WriteLine("是否是后台线程:" + th.IsBackground);
Console.WriteLine("是否属于托管线程池:" + th.IsThreadPoolThread);
Console.WriteLine("线程优先级:" + th.Priority);
Console.WriteLine("线程状态:" + th.ThreadState.ToString());
}

线程的常用方法

Thread 中包括了多个方法来控制线程的创建、挂起、停止、销毁,以后来的例子中会经常使用。

方法 描述
Abort() 终止本线程
GetDomain() 返回当前线程正在其中运行的当前域
GetDomainId() 返回当前线程正在其中运行的当前域Id
Interrupt() 中断处于 WaitSleepJoin 线程状态的线程
Join() 阻塞调用线程,直到某个线程终止时为止。
Resume() 继续运行已挂起的线程
Start() 执行线程
Suspend() 挂起当前线程
Sleep() 睡眠,把正在运行的线程挂起一段时间
1
2
3
4
5
6
7
8
9
10
11
static void Fun3()
{
Console.WriteLine("hello");
Console.WriteLine("休息一下,3秒后继续...");

//睡眠
Thread.Sleep(3000);

Console.WriteLine("c#");

}

线程的优先级

当线程之间争夺CPU时间时,CPU按照线程的优先级给予服务。高优先级的线程可以完全阻止低优先级的线程执行。.NET为线程设置了Priority属性来定义线程执行的优先级别,里面包含5个选项,其中Normal是默认值。除非系统有特殊要求,否则不应该随便设置线程的优先级别。

级别 说明
Lowest 可以将Thread 安排在具有任何其他优先级的线程之后
BelowNormal 可以将Thread 安排在具有 Normal 优先级的线程之后,在具有Lowest 优先级的线程之前。
Normal 默认选择 ,可以将 Thread 安排在具有 AboveNormal 优先级线程之后,在具有BelowNormal 优先级的线程之前。
AboveNormal 可以将 Thread 安排在具有 Highest 优先级的线程之后,在具有 Normal 优先级线程之前。
Highest 可以将 Thread 安排在具有任何其他优先级的线程之前。

线程的状态

通过ThreadState可以检测线程是处于Unstarted、Sleeping、Running 等等状态,它比 IsAlive 属性能提供更多的特定信息。

状态 说明
Unstarted 线程已创建但尚未启动
Running 线程正在运行
WaitSleepJoin 线程正在等待另一个线程完成
Suspended 线程已经启动,但已被挂起。(已废弃)
AbortRequested 线程已请求中止,但还未中止。(已废弃)
Stopped 线程已停止。(已废弃)
Aborted 线程已中止
Stopped 线程已经终止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void Fun5()
{
Thread thread = new Thread(Test3);
Console.WriteLine("线程启动前: " + thread.ThreadState);
thread.Start();
Console.WriteLine("线程运行中: " + thread.ThreadState);
thread.Join();
Console.WriteLine("线程终止后: " + thread.ThreadState);
}

static void Test3()
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(1000);
}
}

前台线程与后台线程

(1)后台线程,界面关闭,线程也就随之消失
(2)前台线程,界面关闭,线程会等待执行完才结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void button1_Click(object sender, EventArgs e)
{
//前台线程
Thread th = new Thread(Test1);
th.Start();
}

private void button2_Click(object sender, EventArgs e)
{
//后台线程
Thread th = new Thread(Test1);
th.IsBackground = true;
th.Start();
}

public void Test1()
{
for (int i = 0; i < 30; i++)
{
for (int j = 0; j < 10; j++)
{
Console.WriteLine("hello");
}

Thread.Sleep(1000);
}
}

线程同步

所谓同步:是指在某一时刻只有一个线程可以访问变量。
如果不能确保对变量的访问是同步的,就会产生错误。
c#为同步访问变量提供了一个非常简单的方式,即使用c#语言的关键字Lock,它可以把一段代码定义为互斥段,互斥段在一个时刻内只允许一个线程进入执行,而其他线程必须等待。在c#中,关键字Lock定义如下:

1
2
3
4
5
6
Lock(expression)
{

statement_block

}

expression代表你希望跟踪的对象: 如果你想保护一个类的实例,一般地,你可以使用this; 如果你想保护一个静态变量(如互斥代码段在一个静态方法内部),一般使用类名就可以了 而statement_block就算互斥段的代码,这段代码在一个时刻内只可能被一个线程执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class BookShop
{
/// <summary>
/// 图书数量
/// </summary>
public int num = 1;

/// <summary>
/// 买书
/// </summary>
public void Sale()
{
////判断是否有书,如果有就可以卖
//if (num > 0)
//{
// Thread.Sleep(1000);

// num -= 1;
// Console.WriteLine("{0}售出一本图书,还剩余{1}本",Thread.CurrentThread.Name, num);
//}
//else
//{
// Console.WriteLine("{0}售出0本图书,没有了", Thread.CurrentThread.Name);
//}

lock (this)
{
//判断是否有书,如果有就可以卖
if (num > 0)
{
Thread.Sleep(1000);

num -= 1;
Console.WriteLine("{0}售出一本图书,还剩余{1}本", Thread.CurrentThread.Name, num);
}
else
{
Console.WriteLine("{0}售出0本图书,没有了", Thread.CurrentThread.Name);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void Fun6()
{
//图书商店
BookShop book = new BookShop();

//创建两个线程同时访问Sale方法
Thread t1 = new Thread(new ThreadStart(book.Sale));
t1.Name = "张三";
Thread t2 = new Thread(new ThreadStart(book.Sale));
t2.Name = "李四";

//启动线程
t1.Start();
t2.Start();

}

跨线程访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void button1_Click(object sender, EventArgs e)
{
//创建一个线程
Thread thread = new Thread(new ThreadStart(Test));

//Start方法标记这个线程就绪了,可以随时被执行,具体什么时候执行这个线程,由CPU决定
//将线程设置为后台线程
thread.IsBackground = true;
thread.Start();

}

private void Test()
{
for (int i = 0; i < 10000; i++)
{
this.textBox1.Text = i.ToString();
}
}

产生错误的原因:textBox1是由主线程创建的,thread线程是另外创建的一个线程,在.NET上执行的是托管代码,C#强制要求这些代码必须是线程安全的,即不允许跨线程访问Windows窗体的控件。

解决方案:

1、在窗体的加载事件中,将C#内置控件(Control)类的CheckForIllegalCrossThreadCalls属性设置为false,屏蔽掉C#编译器对跨线程调用的检查。

1
2
3
4
5
6
private void Form1_Load(object sender, EventArgs e)
{
//取消跨线程的访问
Control.CheckForIllegalCrossThreadCalls = false;
}

使用上述的方法虽然可以保证程序正常运行并实现应用的功能,但是在实际的软件开发中,做如此设置是不安全的(不符合.NET的安全规范),在产品软件的开发中,此类情况是不允许的。如果要在遵守.NET安全标准的前提下,实现从一个线程成功地访问另一个线程创建的空间,要使用C#的方法回调机制。

2、使用回调函数

(1)、定义、声明回调函数的委托。

1
2
3
4
5
//定义回调函数的委托
private delegate void setTextValueCallBack(int value);

//声明委托
private setTextValueCallBack setCallBack;

(2)、初始化回调委托对象

1
2
//实例化委托对象
setCallBack = new setTextValueCallBack(SetValue);

(3)、触发对象动作

1
2
//使用回调
textBox1.Invoke(setCallBack, i);

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using static System.Net.Mime.MediaTypeNames;

namespace CH08Demo_2
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}

//定义回调函数的委托
private delegate void setTextValueCallBack(int value);

//声明委托
private setTextValueCallBack setCallBack;

//声明线程
Thread thread;

private void button1_Click(object sender, EventArgs e)
{
//实例化委托对象
setCallBack = new setTextValueCallBack(SetValue);

//创建一个线程
thread = new Thread(new ThreadStart(Test));

//Start方法标记这个线程就绪了,可以随时被执行,具体什么时候执行这个线程,由CPU决定
//将线程设置为后台线程
thread.IsBackground = true;
thread.Start();

}

private void Test()
{
for (int i = 0; i < 10000; i++)
{
//this.textBox1.Text = i.ToString();

//使用回调
textBox1.Invoke(setCallBack, i);
}
}

private void Form1_Load(object sender, EventArgs e)
{

//取消跨线程的访问
//Control.CheckForIllegalCrossThreadCalls = false;
}


/// <summary>
/// 定义回调使用的方法
/// </summary>
/// <param name="value"></param>
private void SetValue(int value)
{
this.textBox1.Text = value.ToString();
}
}
}

终止线程

若想终止正在运行的线程,可以使用Abort()方法。

1
2
3
4
5
6
7
private void button2_Click(object sender, EventArgs e)
{
if (thread!=null && thread.IsAlive)
{
thread.Abort();
}
}

应用案例

假设有100张票,可同时由多个销售员销售,直到全部销售完为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TicketSeller
{
private int tickets = 100; // 假设有100张票

public void SellTicket()
{
while (true)
{
//票数递减
int currentTickets = Interlocked.Decrement(ref tickets);

//判断剩余票数
if (currentTickets < 0)
{
Console.WriteLine("售罄");
break;
}
else
{
Console.WriteLine($"{Thread.CurrentThread.Name}卖出票{100 - currentTickets},剩余票数:{currentTickets}");
Thread.Sleep(1000);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void Fun12()
{
TicketSeller seller = new TicketSeller();

Thread thread1 = new Thread(seller.SellTicket);
thread1.Name = "张三";
Thread thread2 = new Thread(seller.SellTicket);
thread2.Name = "李四";
Thread thread3 = new Thread(seller.SellTicket);
thread3.Name = "王五";

thread1.Start();
thread2.Start();
thread3.Start();

}

线程池-ThreadPool

线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。线程池线程都是后台线程。每个线程都使用默认的堆栈大小,以默认的优先级运行,并处于多线程单元中。如果某个线程在托管代码中空闲(如正在等待某个事件),则线程池将插入另一个辅助线程来使所有处理器保持繁忙。如果所有线程池线程都始终保持繁忙,但队列中包含挂起的工作,则线程池将在一段时间后创建另一个辅助线程但线程的数目永远不会超过最大值。超过最大值的线程可以排队,但他们要等到其他线程完成后才启动。

线程池的主要方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 参数:
// workerThreads:
// 要由线程池根据需要创建的新的最小工作程序线程数。
// completionPortThreads:
// 要由线程池根据需要创建的新的最小空闲异步 I/O 线程数。 // 返回结果:如果更改成功,则为 true;否则为 false。
[SecuritySafeCritical]
public static bool SetMinThreads(int workerThreads, int completionPortThreads);
// 参数:
// workerThreads:
// 线程池中辅助线程的最大数目。
// completionPortThreads:
// 线程池中异步 I/O 线程的最大数目。
// 返回结果:如果更改成功,则为 true;否则为 false。
[SecuritySafeCritical]
public static bool SetMaxThreads(int workerThreads, int completionPortThreads);

案例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace CH08Demo_3
{
internal class Program
{
const int num = 10;

static void Main(string[] args)
{
//最小活动线程
ThreadPool.SetMinThreads(1, 1);

//最大活动线程
ThreadPool.SetMaxThreads(5, 5);

for (int i = 0; i < num; i++)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(Test),i.ToString());
}
Console.WriteLine("主线程执行!");
Console.WriteLine("主线程结束!");

Console.ReadKey();
}

public static void Test(object obj)
{
Console.WriteLine($"{DateTime.Now.ToString()}:第{obj.ToString()}个线程");
}
}
}

这里可以看出,线程池里线程的执行不影响主线程的运行,线程池虽然可以管理多线程的执行,但是却无法知道它什么时候终止。这时候我们可以利用信号灯AutoResetEvent(自动重置事件)和ManualResetEvent(手动重置事件)来解决问题 。

上面的案例升级后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace CH08Demo_3
{
internal class Program
{
const int num = 10;
static int count = 10;

//自动重置事件
static AutoResetEvent myEvent = new AutoResetEvent(false);

static void Main(string[] args)
{
//最小活动线程
ThreadPool.SetMinThreads(1, 1);

//最大活动线程
ThreadPool.SetMaxThreads(5, 5);

for (int i = 0; i < num; i++)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(Test),i.ToString());
}
Console.WriteLine("主线程执行!");
Console.WriteLine("主线程结束!");

//组织当前线程,直到当前线程收到信号
myEvent.WaitOne();
Console.WriteLine("线程池终止!");

Console.ReadKey();
}

public static void Test(object obj)
{
//int count = Convert.ToInt32(obj);
count--;
Console.WriteLine($"{DateTime.Now.ToString()}:第{obj.ToString()}个线程");

Thread.Sleep(1000);
if (count == 0)
{
//将事件状态设置为有信号
myEvent.Set();
}
}
}
}

线程的同步与异步

同步(Sync)

  • 任务按顺序依次执行,一个任务的完成通常需要等待前一个任务完成后才能进行。
  • 调用同步函数时,程序会被阻塞,直到函数执行完成并返回结果后,程序才会继续执行后续的代码。
  • 同步操作简单直观,但可能导致程序性能下降,特别是当某个任务需要花费较长时间时。

异步(Async)

  • 任务的执行是并行或者并发的,不需要等待前一个任务完成后才能进行下一个任务。
  • 调用异步函数时,程序会继续执行后续的代码,不会被阻塞,任务的完成通过回调函数、事件或其他机制通知。
  • 异步操作通常能提高程序的性能和响应速度,特别是在需要处理大量并发任务或I/O密集型任务时。

任务-Task

Task出现背景

在前面的章节介绍过,Task出现之前,微软的多线程处理方式有:Thread→ThreadPool→委托的异步调用,虽然也可以基本业务需要的多线程场景,但它们在多个线程的等待处理方面、资源占用方面、线程延续和阻塞方面、线程的取消方面等都显得比较笨拙,在面对复杂的业务场景下,显得有点捉襟见肘了。正是在这种背景下,Task应运而生。

Task是微软在.Net 4.0时代推出来的,也是微软极力推荐的一种多线程的处理方式,Task看起来像一个Thread,实际上,它是在ThreadPool的基础上进行的封装,Task的控制和扩展性很强,在线程的延续、阻塞、取消、超时等方面远胜于Thread和ThreadPool。

Task开启线程的方式

(1)new Task().Start()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void Fun8()
{
Task task = new Task(() =>
{
Test2("张三");
});
task.Start();

Task<int> task2 = new Task<int>(() =>
{
return DateTime.Now.Year;
});
task2.Start();

int result = task2.Result;
Console.WriteLine($"result:{result}");
}

(2)Task.Run()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void Fun9()
{
Task.Run(() =>
{
Test2("张三");
});

Task<int> task2 = Task.Run<int>(() =>
{
return DateTime.Now.Year;
});

int result = task2.Result;
Console.WriteLine($"result:{result}");
}

(3)Task.Factory.StartNew()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void Fun10()
{
TaskFactory factory = Task.Factory;
factory.StartNew(() =>
{
Test2("张三");
});

Task<int> task2 = factory.StartNew<int>(() =>
{
return DateTime.Now.Year;
});
int result = task2.Result;
Console.WriteLine($"result:{result}");
}

(4)new Task().RunSynchronously()(同步方式,上面三种异步方式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void Fun11()
{
Task task = new Task(() =>
{
Test2("张三");
});
task.RunSynchronously();

Task<int> task2 = new Task<int>(() =>
{
return DateTime.Now.Year;
});
task2.RunSynchronously();

int result = task2.Result;
Console.WriteLine($"result:{result}");
}

使用异步方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private async void button3_Click(object sender, EventArgs e)
{
await DoAsyncOperation1();
int r=await DoAsyncOperation2();

MessageBox.Show(r.ToString());

}

/// <summary>
/// 异步方法
/// </summary>
/// <returns></returns>
public async Task DoAsyncOperation1()
{
await Task.Run(async () =>
{
for (int i = 0; i < 100; i ++)
{
Console.WriteLine($"输出数字{i}");

await Task.Delay(300);
}
});
}

/// <summary>
/// 异步方法
/// </summary>
/// <returns></returns>
public async Task<int> DoAsyncOperation2()
{
int r;
r=await Task.Run(async () =>
{
int result = 0;
for (int i = 0; i < 100; i++)
{
result += i;

await Task.Delay(300);
}

return result;
});
return r;
}

多线程的优缺点

优点

可以提高CPU的利用率。在多线程程序中,一个线程必须等待的时候,CPU可以运行其它的线程而不是等待,这样就大大提高了程序的效率。(牺牲空间计算资源,来换取时间)

缺点

  • 线程也是程序,所以线程运行需要占用计算机资源,线程越多占用资源也越多。(占内存多)
  • 多线程需要协调和管理,所以需要CPU跟踪线程,消耗CPU资源。(占cpu多)
  • 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。多线程存在资源共享问题)
  • 线程太多会导致控制太复杂,最终可能造成很多Bug。(管理麻烦,容易产生bug)

多线程的使用场景

  • 当主线程试图执行冗长的操作,但系统会卡界面,体验非常不好,这时候可以开辟一个新线程,来处理这项冗长的工作。
  • 当请求别的数据库服务器、业务服务器等,可以开辟一个新线程,让主线程继续干别的事。
  • 利用多线程拆分复杂运算,提高计算速度。

本章总结

课后作业