异步和并行都是并发的一种手段。并发是指多个任务的执行有重叠的时间,
即在一个任务没有完全执行的时候就执行另一个任务。
异步是一个任务中可以暂停去做别的事情,在纯等待任务中,可以利用这个空余时间做别的事情。
否则仅能够提供程序的响应能力,例如暂停和取消这种必须立刻响应的操作。
并行是利用CPU的多个逻辑处理器同时执行,是真正能提高程序执行效率的。
多线程是实现并行的一种手段。在一个程序进程里的多个执行单元,
不同的线程可以共享这个进程下的数据。当然直接开好几个程序,也是一种并行手段。
利用多线程优化程序执行效率,有两个前提。
每个线程都有一个线程栈控制这个程序的执行。
线程栈主要用于存储局部变量、方法调用,运行到哪之类的执行信息。
每个线程栈都是独立的运作的,不会和其他线程交互。但他们能共享访问托管堆上的数据。
在VS2022中默认情况下一个线程栈是4M大小。对于线程栈的分配和回收需要消耗很多的资源。
因此大多数情况下都会通过线程池来获取线程并分配任务。
线程池会给你储存的空余线程,在你使用完毕后会把他休眠但不会销毁。
这样下次用的时候就直接把他给你,不需要创建线程,也不需要销毁线程。
在c#中我们可以直接使用Task.Run
来分配线程任务。因为任务调度器和任务池,也使用了线程池。
Task.Run(() =>
{
for (int i = 0; i < 200; i++)
{
Console.Write("x");
}
});
for (int i = 0; i < 200; i++)
{
Console.Write("y");
}
但是这个方法创建出来的线程任务默认为后台线程。
一个程序会在所有前台线程停止时就终止,无论此时后台线程如何。
创建Task
的时候如果传入配置,可以将他改为前台线程。
// 使用TaskCreationOptions.LongRunning选项来创建前台线程
var task = Task.Factory.StartNew(() =>
{
for (int i = 0; i < 200; i++)
{
Console.Write("x");
}
}, TaskCreationOptions.LongRunning);
Task里包含的都是普通c#语句,所以异步的并发至少也会完整运行一个语句再切换。
但多线程不会,最小单元是一个CPU操作,一个i++
分为取值,运算,赋值3步。
多线程环境下可能会在一个线程完成赋值之前,其他线程就进行取值操作了。
这两个线程算完了结果也只相当于执行一次i++
。
int num = 0;
for (int i = 0; i < 1000; i++)
{
_=Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
num++;
}
});
}
await Task.Delay(100);
Console.WriteLine(num);
一种最简单的方法是利用锁,锁可以让一个资源同时只能由一个线程进行访问。
简单有效,但用不好的锁会让性能变得和单线程一样。
锁使用lock
语句,就像using
语句一样,原理都是扩展成一个try-finally
块,
在开始的位置加锁,在finally
中解锁。
只有引用类型才有对象头和同步块,因此没有装箱的值类型是无法加锁的。
int[] arr = { 0 };
for (int i = 0; i < 1000; i++)
{
_ = Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
lock (arr)
{
arr[0]++;
}
}
});
}
await Task.Delay(100);
Console.WriteLine(arr[0]);
lock
块内不能存在await
。
如果需要使用异步锁则不能使用这个语法,需要使用完整的类实例进行加锁。
int num2 = 0;
SemaphoreSlim slim = new SemaphoreSlim(1);
for (int i = 0; i < 1000; i++)
{
_ = Task.Run(async () =>
{
for (int i = 0; i < 1000; i++)
{
await slim.WaitAsync();
try
{
await Task.Yield();
num2++;
}
finally
{
slim.Release();
}
}
});
}
await Task.Delay(100);
Console.WriteLine(num2);
SemaphoreSlim
类也能实现加锁的功效。构造器里的1就是指最大运行1个计数。
这个类也可以简单实现最大同时下载任务数量,最大同时运行任务数量等功能。
他的计数器是通过slim.WaitAsync()
和slim.Release()
手动改变的,他并不知道你是否真的使用了。
在await slim.WaitAsync();
时,如果计数不少于允许数量,这个异步就不会完成。
如果完成了,也会安全的随机让一个线程进入,不会同时进入多个线程。
为了保证计数一定会被释放,他的使用也应该放在try-finally
块中。
防止出现异常中断的时候,不会释放计数。
锁的确简单好用。但是太过简单的东西却有一种致命性的问题,死锁。
例如A线程锁住了资源a1,B线程锁住了资源b1。
在加锁完成后,线程A希望访问b1,线程B希望访问a1。
因为b1是加锁的,线程A无法访问,线程A无法执行,所以资源a1不会释放。
因为a1不会释放,线程B也无法访问a1,所以b1也不会被释放。
避免死锁的同时还能保证最大效率,这只有精通多线程的人才写的出来。
对新手而言避免死锁的简单办法就是从破坏他的条件入手,
尽管会损失效率,但能很大程度上避免死锁。
互斥就是使用加锁,一个线程访问时另一个线程不能访问。
不加锁当然不会造成死锁。
一些只读数据,和每次访问时都创建一个复制,是不需要锁就能访问。
但这样做要么无法操作数据,要么还是不能同步数据。
一个线程在无法访问其他资源时,自己只干等,等到它能用为止。
解决这个条件的办法是超时。如果经过一段时间就主动放手。
异步是支持超时操作的,而SemaphoreSlim
也集成了超时锁的方法。
await slim.WaitAsync(10000);//10秒钟超时
在放手以后可以在一定时间间隔后重试,但这样要思考合适的间隔时间和重试次数。
锁的东西应该是自己能预测的不是共享的,不要锁this
,string
,Type
这种别人也能访问到的东西。
私有化锁的东西后,什么东西会被锁就应该是可控的。
最好的办法就是在类里创建静态的object
或使用SemaphoreSlim
类来加锁。
然后,如果你在锁了一个东西后,你发现要使用另一个可能会被所住的东西,
你应该先释放自己占用的东西然后才对新东西加锁。这样一段代码内不会同时访问两个加锁的东西。
例子中的A线程锁住a1,B线程锁住b1,本身就反直觉。
因为多线程执行的应该是相同的操作,他们锁的东西和顺序应该是相同的。
这种情况发生说明他们使用了流程控制语句。
所以解决死锁的一个办法是不在流程控制语句里加锁。
原子这个词起源于古希腊语的"不可分割的"。
原子操作是指要么完全执行,要么完全不执行,不会再被细分只执行一部分的操作。
在 C# 中,有一个System.Threading.Interlocked
类,提供了一些原子操作的方法,例如:
Interlocked.Increment
:原子地递增一个整数值,并返回新值。Interlocked.Decrement
:原子地递减一个整数值,并返回新值。Interlocked.Exchange
:原子地将一个变量的值设置为另一个值,并返回旧值。Interlocked.CompareExchange
:原子地比较两个变量的值,如果相等,则将第一个变量的值设置为另一个值,并返回旧值。原子操作通常是通过 CPU 指令或者内存屏障来实现的,
所以没有直接给int
这些基础类型的运算直接做成原子操作。
这些方法的参数全部都是引用变量。直接对内存进行操作来保证完成。
使用原子操作的方法时,需要注意以下几点:
顺序性是指,没有依赖数据的操作,会被编译器认为谁先执行谁后执行没有关系。例如:
int a = 1; int b = 2; int c = a + b;
c
的赋值对a
和b
有依赖,但a
和b
的声明和赋值的顺序无关紧要。编译器可能会根据CPU调整顺序。
可见性是指,一些数据可能会放在CPU缓存中。使用原子操作更新内容后,会让那些缓存失效,
他们必须从内存获取最新数据。所以可以减少数据不一致的可能性。
对Linq序列使用AsParallel()
方法能切换为并行Linq序列。
并行Linq序列会对数据进行分区来分布成多个任务并行执行。
但并行Linq有两个缺点:
AsOrdered
方法可以保持顺序。AsUnordered
方法表示不需要再维持顺序。AsSequential
方法会再转为普通Linq序列。