C#课程2(事件、异步、多线程)
一、事件(event)
通俗来讲,事件是一种广播机制
比如某个组件订阅了一个事件,那么在这个事件被触发的时候组件会收到通知。
例:小明家里有一部电话,有一个正在烧水的热水壶,电话响了(这是一个event)就会触发小明接电话(这是一个方法)这个动作。(*注:这部分代码参考Event.cs)
事件可以实现底层程序集调用高层程序集的方法。并且在调用的时候可以向高层程序集的方法传入参数。
例:我写了一个库,这个库给出一个Read的事件,这个事件会在控制台输入字符串的时候被调用。(*注:这部分代码参考Event2.cs)
二、多线程
1. 简介
进程是操作系统执行程序的基本单位
一个进程拥有多个线程,这些线程之间资源共享。
使用线程进行并发式编程。
C#提供了完整的多线程库
2. 常用方法
以下代码显示了如何创建一个线程
using System.Threading; // 包含多线程需要的库
public static void Function() // 一个需要被线程调用的函数
{
Console.WriteLine("Function !");
}
Thread t = new Thread(Function); // 用Function函数初始化线程
上面的代码并没有让线程运行,如果要让线程开始运行,需要调用 Start() 方法。
t.Start();
如果Function的定义修改为如下
public static void Function(string str);
那么在Start()方法中就要传入参数。
t.Start("Hello World");
使用Sleep()方法可以暂停线程一段时间,单位为毫秒。
Thread.Sleep(1000) // 线程停顿1秒
使用Join()方法可以阻塞当前线程,等待某一个线程执行完毕后再进行后面的任务。比如下面的代码,展示了Join()方法的阻塞作用
public static void Function()
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(200);
Console.WriteLine("Function !");
}
}
public static void Run()
{
Thread t = new Thread(Function);
t.Start();
Thread.Sleep(500);
t.Join(); // 这里阻塞了Run()方法所在线程,等待t执行完毕
Console.WriteLine("Finish !");
}
注意!非常不建议使用Suspend()、Abort()等方法在线程外部暂停或终止一个线程。
3. 常用属性
- IsBackground 属性,显示一个线程是否为后台线程。
- 如果一个线程为后台线程,那么程序不会等待这个线程执行完毕;如果一个线程是前台线程,程序会等待线程执行完毕再退出。
- IsAlive 属性,显示一个线程是否正在运行。
- IsThreadPoolThread 属性,显示线程是否属于托管线程池。
下面的代码展示了如何控制一个线程是否为后台线程
new Thread(() =>
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(300);
Console.WriteLine("Thread1 : " + i);
}
})
{ IsBackground = true }.Start();
new Thread(() =>
{
for (int i = 0; i < 6; i++)
{
Thread.Sleep(300);
Console.WriteLine("Thread2 : " + i);
}
})
{ IsBackground = false }.Start();
三、异步编程
1. 简介
C# 引入了async和await关键字后,异步编程变得十分方便
实际上异步编程也是多线程的一种,只不过不需要手动创建线程,线程从线程池中被创建
例:比如我们在处理UI和按钮单击时,需要运行一个长时间运行的方法,比如读取一个大文件,如果采用同步编程,整个程序必须等待这个任务完成才能执行其他任务。
2. Task介绍
使用Task可以很方便地创建一个任务并运行,下面的代码展示了如何创建任务。
Task.Run(Method1);
还可以采用其他形式创建任务
Task task = new Task(Method1);
task.Start();
还可以采用Factory创建并运行任务
Task.Factory.StartNew(Method1);
Task的生命周期:Task.Status
Task的生命周期有如下几种状态:
- Created : 已经被创建但还未开始运行
- Running : 正在运行
- RanToCompletion : 执行完毕
下面的代码展示了Task的生命周期:
public static void Method1()
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("Method1 : " + i);
}
}
public static void Run()
{
Task task = new Task(Method1);
Console.WriteLine(task.Status);
task.Start();
for (int i = 0; i < 7; i++)
{
Thread.Sleep(200);
Console.WriteLine(task.Status);
}
}
输出结果如下:
Created
Running
Method1 : 0
Method1 : 1
Running
Running
Method1 : 2
Method1 : 3
Running
Running
Method1 : 4
RanToCompletion
RanToCompletion
Task的任务控制,可以控制多个Task的执行顺序
Task.Wait()方法等待任务执行完毕
Task task1 = new Task(Method1);
task1.Start();
task.Wait(); // 等待任务执行完毕
Console.WriteLine("task Finish");
Task.WaitAll()等待所有任务执行完毕
Task task1 = new Task(Method1);
task1.Start();
Task task2 = new Task(Method2);
task2.Start();
Task.WaitAll(task1, task2);
Console.WriteLine("task Finish");
Task.WaitAny() 等待任一任务执行完毕
Task task1 = new Task(Method1);
task1.Start();
Task task2 = new Task(Method2);
task2.Start();
Task.WaitAny(task1, task2);
Console.WriteLine("task Finish");
Task 返回值 Task< TResult >
任何一个异步Task都可以返回一个值,下面的代码展示了如何获取Task的返回值。
public static string MethodReturn()
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("MethodReturn : " + i);
}
return "MethodReturn finish";
}
Task<string> taskReturn = new Task<string>(MethodReturn);
taskReturn.Start();
Console.WriteLine("Task finish with : " + taskReturn.Result);
可以看到只需要调用Task.Result就可以获取Task的返回值,并且在尝试获得Task的返回值时会自动阻塞当前线程,直到Task结束。
3. async 和 await
await 和字面意思一样,就是等待,下面的代码展示了等待一个任务执行完毕
public static async void Run()
{
await Task.Run(Method1);
Console.WriteLine("Task1 finish ");
}
await 更大的用处是等待一个有返回值的Task,如下代码所示:
public static async void Run()
{
Task<string> task = new Task<string>(MethodReturn);
task.Start();
Console.WriteLine("Task1 finish with : " + await task);
}
async 的意思也和字面意思完全一样,就是表明一个方法是异步方法,下面展示了一个异步方法:
public static async void TestAsync()
{
await Task.Run(
() =>
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("TestAsync : " + i);
}
});
}
public static async void Run()
{
TestAsync();
Console.WriteLine("Task1 finish ");
}
可以注意到凡是方法体里出现了await关键字的,方法定义必然要出现async关键字,这是显然的,因为凡是有await关键字的方法必为异步方法。
需要注意的是如果一个被async修饰的方法里面没有await关键字的话,那么这个方法和同步方法没有区别。
一个async方法里面的异步操作仅出现在await关键字之后。
public static async void TestAsync()
{
for (int i = 0; i < 3; i++)
{
Thread.Sleep(200);
Console.WriteLine("Sync : " + i);
}
await Task.Run(
() =>
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("TestAsync : " + i);
}
});
}
public static async void Run()
{
TestAsync();
Console.WriteLine("Task1 finish ");
}
因为async方法为异步方法,所以调用async方法的时候常常也使用await。
public static async Task<string> AsyncReturn()
{
await Task.Run(
() =>
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("AsyncReturn : " + i);
}
});
return "AsyncReturn finish";
}
public static async void Run()
{
var str = await AsyncReturn();
Console.WriteLine("Task1 finish with : " + str);
}
注意:async方法的返回值只能为void、Task、Task< TResult >,不能为其他
四、锁
多线程编程过程中常常会出现多个线程同时访问一个资源的情况,这个时候就需要用到锁,把共享的资源锁起来。
一个不使用锁的例子:
int num = 0;
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
num++;
}
Console.WriteLine("Thread 1 : " + num);
}
);
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
num++;
}
Console.WriteLine("Thread 2 : " + num);
}
);
锁的使用非常简单,只需要使用 lock 关键字就可以锁住一个对象,一个对象同时只允许一个线程访问,其他线程在这个期间会被阻塞。
int num = 0;
object numlock = new object();
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
lock (numlock)
num++;
}
lock (numlock)
Console.WriteLine("Thread 1 : " + num);
}
);
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
lock (numlock)
num++;
}
lock (numlock)
Console.WriteLine("Thread 2 : " + num);
}
);
下面这段代码展示了锁是如何阻塞线程的:
object tasklock = new object();
Task.Run(() =>
{
lock (tasklock)
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("Thread 1");
}
}
);
Task.Run(() =>
{
lock (tasklock)
for (int i = 0; i < 5; i++)
{
Thread.Sleep(200);
Console.WriteLine("Thread 2");
}
}
);
除了使用 lock 关键字,还可以使用 Moniter 来锁定和释放对象。
Monitor.Enter(objlock);
Monitor.Exit(objlock);
注意:
- 应避免锁定 public 对象,否则实例将超出代码的控制范围。
- 锁定对象应当为 readonly 以免在锁定的过程中发生改变
- 不能锁定值类型
lock(9) // 错误,不能锁定值类型
lock(this) // 错误,违反了不能锁定 public 对象的规范
lock("Hello World") // 错误,相同字符串同一程序中只有一个实例对象,相当于锁定 public 对象。
下面是正确示例
private static readonly object objlock = new object();
lock(objlock)
...
读写锁
允许多个线程同时获取读锁,但同一时间只允许一个线程获得写锁
当某个线程进入读取模式时,此时其他线程依然能进入读取模式,假设此时一个线程要进入写入模式,那么他不得不被阻塞。
如果某个线程进入了写入模式,那么其他线程无论是要写入还是读取,都是会被阻塞的。
ReaderWriterLockSlim rw = new ReaderWriterLockSlim();
int num = 5;
for (int i = 0; i < 5; i++)
{
int tmp = i;
Task.Run(() =>
{
rw.EnterReadLock();
for (int j = 0; j < 5; j++)
{
Thread.Sleep(200);
Console.WriteLine("Thread " + tmp + " is reading");
}
rw.ExitReadLock();
});
}
上面的代码展示了多个线程可以同时获取读锁。
ReaderWriterLockSlim rw = new ReaderWriterLockSlim();
int num = 5;
for (int i = 0; i < 5; i++)
{
int tmp = i;
Task.Run(() =>
{
for (int j = 0; j < 5; j++)
{
rw.EnterReadLock();
Thread.Sleep(200);
Console.WriteLine("Thread " + tmp + " is reading");
rw.ExitReadLock();
}
});
}
Thread.Sleep(400);
Task.Run(() =>
{
rw.EnterWriteLock();
for (int j = 0; j < 5; j++)
{
Thread.Sleep(200);
Console.WriteLine("Thread is writing");
}
rw.ExitWriteLock();
});
上面的代码展示了当一个线程获取写锁的时候其他线程都被阻塞。
死锁的产生
考虑下面的程序运行过程:
Thread1 : lock 1 -> attempt to lock 2
Thread2 : lock 2 -> attempt to lock 1
这是一个最简单的死锁例子。两个线程都无法继续运行下去,程序产生了死锁。
object lock1 = new object();
object lock2 = new object();
Task.Run(() =>
{
lock (lock1)
{
Console.WriteLine("Thread 1 has lock 1");
Thread.Sleep(1);
Console.WriteLine("Thread 1 attempt to lock 2");
lock (lock2)
{
Console.WriteLine("Thread 1 has lock 2");
}
}
});
Task.Run(() =>
{
lock (lock2)
{
Console.WriteLine("Thread 2 has lock 2");
Thread.Sleep(1);
Console.WriteLine("Thread 2 attempt to lock 1");
lock (lock1)
{
Console.WriteLine("Thread 2 has lock 1");
}
}
});
哲学家就餐问题
有五个哲学家,他们的生活方式是交替地进行思考和进餐。他们共用一张圆桌,分别坐在五张椅子上。
在圆桌上有五个碗和五支筷子,平时哲学家进行思考,饥饿时便试图取用其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕,放下筷子又继续思考。
死锁的产生:五位哲学家同时饥饿而各自拿起了左边的筷子,当他们试图去拿起右边的筷子时,都将因无筷子而无限期地等待下去。
死锁产生的条件
- 互斥条件。即某个资源在一段时间内只能由一个进程占有
- 不可抢占条件。进程所获得的资源在未使用完毕之前,资源申请者不能强行地从资源占有者手中夺取资源
- 占有且申请条件。进程至少已经占有一个资源,但又申请新的资源
- 循环等待条件。存在一个进程等待序列{P1,P2,...,Pn},其中P1等待P2所占有的某一资源,P2等待P3所占有的某一源,......
死锁的解决
- 打破互斥条件,允许进程同时访问某些资源(废话)
- 打破不可抢占条件,即允许进程强行从占有者那里夺取某些资源(也是废话)
- 打破占有且申请条件。可以实行资源预先分配策略。即进程在运行前一次性地向系统申请它所需要的全部资源(很难满足条件)
- 资源编号,线程在申请资源时,必须按顺序申请(实现简单,常用)
- 银行家算法(有一定的限制条件)
资源编号策略:
- 1 号哲学家拿起 1 号筷子
- 2 号哲学家拿起 2 号筷子
- 3 号哲学家拿起 3 号筷子
- 4 号哲学家拿起 4 号筷子
- 5 号哲学家拿起 ? 号筷子
“避免死锁的最好办法,就是不要用锁。” 著名哲学家沃尔兹基说。
五、原子操作
就如其字面意思,原子操作就是计算机中不可再分割的操作。
C# 提供了一个原子操作库 Interlocked ,可以使用 Interlocked 把常见的操作转化为原子操作。
常见的操作有 i++ (自增操作),如果使用 lock 则系统开销过大,不值得使用,可以使用原子操作进行改写。
int num = 0;
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
Interlocked.Increment(ref num);
}
Console.WriteLine("Thread 1 : " + num);
}
);
Task.Run(
() =>
{
for (int i = 0; i < 1000; i++)
{
Interlocked.Increment(ref num);
}
Console.WriteLine("Thread 2 : " + num);
}
);
除了 Increment 之外,还有许多其他原子操作,比如:
Add(ref T a, T b); // a = a + b
Exchange(ref T a, T b); // a = b
CompareExchange(ref T a, T b, int c); // if(b == c) a = b;
Decrement(ref T a); // a--
六、C# 并发集合
C# 默认的集合并不是线程安全的,当多个线程同时对一个集合作修改时可能会发生异常:
List<int> l = new List<int>();
Task.Run(() =>
{
for (int i = 0; i < 10000; i++)
{
l.Add(i);
}
Thread.Sleep(10);
Console.WriteLine(l.Count);
});
Task.Run(() =>
{
for (int i = 0; i < 10000; i++)
{
l.Add(i);
}
Thread.Sleep(10);
Console.WriteLine(l.Count);
});
C# 提供了多个线程安全集合类,这些线程安全集合都是无锁的,能够支持高效的多线程操作
下面以 ConcurrentQueue< T > 为例展示一下线程安全集合类的用法:
ConcurrentQueue<int> q = new ConcurrentQueue<int>(); // 新建一个线程安全队列。
q.Enqueue(9); // 数字 9 入队
q.TryDequeue(out int temp); // 从队列头取出一个数
Console.WriteLine(temp);
注意,Concurrent 集合通常都有 Try 方法,凡是 Try 方法都不一定能执行成功,并且都会返回一个布尔值,用来显示 Try 方法是否执行成功。
Concurrent 命名空间下没有 HashSet< T > 的对应线程安全集合,但可以用 ConcurrentDictionry< T, byte > 来代替。
除了 Concurrent 命名空间下的集合,C# 还提供了 BlockingCollection 阻塞集合。这个集合的特点是当集合为空时,想要从集合里取出元素会被阻塞
BlockingCollection<int> b = new BlockingCollection<int>();
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Thread.Sleep(300);
b.Add(i);
Console.WriteLine("Add " + i);
}
});
Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Take " + b.Take());
}
});
上面这段代码展示了 BlockingCollection 是如何把线程阻塞的。
利用 BlockingCollection 可以实现事件队列,强制把所有事件放在同一线程里执行。
BlockingCollection<Action> EventQueue = new BlockingCollection<Action>();
new Thread(() =>
{
while (true)
EventQueue.Take()();
}).Start();
只需要很少的代码就可以实现事件队列的编写。
我们可以用如下代码测试事件队列:
for (int i = 0; i < 10; i++)
{
Thread.Sleep(300);
Task.Run(() =>
{
EventQueue.Add(() =>
{
Console.WriteLine(DateTime.Now);
});
});
}
七、C#定时器
C# 里提供了几种定时器的实现方法。
System.Threading.Timer
下面的代码展示了如何新建一个定时器
System.Threading.Timer timer = new Timer(
(o) =>
{
Console.WriteLine("Timer !");
}, null, 1000, 200);
Timer() 的构造方法第一个参数为定时执行的回调函数,第二个参数为传递给回调的参数,第三个参数为 duetime ,即在开始计时前等待多长时间,第四个参数为 period ,即定时器周期。
也可以采用如下代码开始一个定时器:
System.Threading.Timer timer = new Timer(
(o) =>
{
Console.WriteLine("Timer !");
});
timer.Change(1000, 200);
上面的定时器在构造时并没有开始运行,在调用 Change() 方法后才开始运行。
System.Threading.Timer 在运行过程中随时可以调用 Change() 方法改变定时器的周期。
System.Timers.Timer
System.Timers.Timer 是比 System.Threading.Timer 更加灵活的一种定时器。
下面的代码展示了如何使用 System.Timers.Timer
System.Timers.Timer timer = new System.Timers.Timer();
timer.Elapsed += (o, e) =>
{
Console.WriteLine("Timer !");
};
timer.Interval = 300;
timer.Enabled = true;
timer.Elapsed 是一个 event ,每个周期都会被调用。
注意: System.Timers.Timer 比 System.Threading.Timer 更加灵活的地方就在于 Elapsed ,你可以在不重新 new 定时器的情况下改变定时器的回调函数。
public static void CallBack(Object o, System.Timers.ElapsedEventArgs e)
{
Console.WriteLine("Timer !");
}
System.Timers.Timer timer = new System.Timers.Timer();
timer.Elapsed += CallBack;
timer.Interval = 300;
timer.Enabled = true;
Thread.Sleep(1200);
timer.Elapsed -= CallBack; // 这里把 CallBack 函数剥离 Elapsed
System.Timers.Timer 的一些属性:
bool Enabled; // 定时器是否正在运行
bool AutoReset; // 定时器是否周期执行,若为false,则表明定时器只执行一次。
int Interval; // 定时器执行的周期
八、C# 线程池
线程池的作用是避免重复创建和销毁线程。
程序里常常会创建大量线程,但这些线程只使用了一小段时间就被挂起或进入休眠,一直等到程序结束都不会再使用。
C# 提供了线程池用于线程复用,当一个异步方法想要调用线程来运行,可以先到线程池里看看有没有空闲线程
如果有空闲线程则调用空闲线程完成任务。
ThreadPool.QueueUserWorkItem(
(o) =>
{
for (int i = 0; i < 5; i++)
{
Thread.Sleep(300);
Console.WriteLine("Thread Pool !");
}
}
);
调用线程池非常简单,只要像上面这段代码一样把一个 Callback 塞进线程就行。
线程池在创建之初会有一个最小线程数,这与计算机的 CPU 核心数有关,可以用 ThreadPool.GetMinThreads() 方法获取。
ThreadPool.GetMinThreads(out int workerThreads, out int IOThreads);
Console.WriteLine(workerThreads + " " + IOThreads);
需要注意的是,Task 和 Timer 也是默认调用线程池里的线程来完成工作的。
如果线程池中的线程被用完了怎么办呢?这个时候系统会停顿 1 秒钟,观察线程池中其他线程是否执行完毕,如果有线程执行完毕的话则调用这个线程,如果没有的话则对线程池进行扩容,增加一个线程。
for (int i = 0; i < 12; i++)
{
int tmp = i;
Task.Run(() =>
{
Console.WriteLine("Thread " + tmp);
Console.ReadKey();
});
}
上面这段代码展示了线程池是如何进行扩容的。
我们可以用 SetMinThreads() 方法设置线程池的最小线程数
ThreadPool.SetMinThreads(16, 16);
设置了最小线程数后再执行上面的代码则不会出现新的任务要等待 1 秒的情况
我们也可以用 SetMaxThreads() 方法设置线程池的最小线程数
ThreadPool.SetMaxThreads(8, 8);
像这样设置了线程池的最大线程数后,上面的代码中 Thread 8 ~ 11 则永远不会被执行,因为 Thread 8 ~ 11 要等到线程池有空闲时才能执行。
线程池的其他方法:
ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads); // 获取线程池中的空闲线程。
ThreadPool.ThreadCount; // 获取线程池中的线程数目