c#笔记-多线程

并行

异步和并行都是并发的一种手段。并发是指多个任务的执行有重叠的时间,
即在一个任务没有完全执行的时候就执行另一个任务。

异步是一个任务中可以暂停去做别的事情,在纯等待任务中,可以利用这个空余时间做别的事情。
否则仅能够提供程序的响应能力,例如暂停和取消这种必须立刻响应的操作。

并行是利用CPU的多个逻辑处理器同时执行,是真正能提高程序执行效率的。
多线程是实现并行的一种手段。在一个程序进程里的多个执行单元,
不同的线程可以共享这个进程下的数据。当然直接开好几个程序,也是一种并行手段。

利用多线程优化程序执行效率,有两个前提。

  1. 必须是CPU密集型。并行只能压榨CPU的性能,所以必须是CPU的工作才能优化。
    一般情况下的任务都不是纯CPU运算,所以多线程仍然要搭配异步使用。
  2. 必须能划分为多个任务。例如压缩,渲染等操作,可以划分多个区块来分配任务。
    无论一个任务的执行结果如何都不影响其他任务,他们之间是独立的。
    但是游戏的下一秒会发生什么,都是基于这一秒的操作的。
    如果提前计算向前走的结果,但是玩家却按了跳跃,那这个计算就没有用。

线程池

每个线程都有一个线程栈控制这个程序的执行。
线程栈主要用于存储局部变量、方法调用,运行到哪之类的执行信息。
每个线程栈都是独立的运作的,不会和其他线程交互。但他们能共享访问托管堆上的数据。
在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中解锁。

扫描二维码关注公众号,回复: 15681873 查看本文章

只有引用类型才有对象头和同步块,因此没有装箱的值类型是无法加锁的。

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秒钟超时

在放手以后可以在一定时间间隔后重试,但这样要思考合适的间隔时间和重试次数。

贪婪

锁的东西应该是自己能预测的不是共享的,不要锁thisstringType这种别人也能访问到的东西。
私有化锁的东西后,什么东西会被锁就应该是可控的。
最好的办法就是在类里创建静态的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的赋值对ab有依赖,但ab的声明和赋值的顺序无关紧要。编译器可能会根据CPU调整顺序。

可见性是指,一些数据可能会放在CPU缓存中。使用原子操作更新内容后,会让那些缓存失效,
他们必须从内存获取最新数据。所以可以减少数据不一致的可能性。

并行Linq

对Linq序列使用AsParallel()方法能切换为并行Linq序列。
并行Linq序列会对数据进行分区来分布成多个任务并行执行。
但并行Linq有两个缺点:

  • 并行Linq无法保证顺序性,和顺序有关的方法几乎和随机一样。保持顺序则要损失很多性能。
    • AsOrdered方法可以保持顺序。
    • AsUnordered方法表示不需要再维持顺序。
    • AsSequential方法会再转为普通Linq序列。
  • 分区本身需要需要消耗资源。对于极小的操作或序列,分区的时间可能比处理时间还多。

猜你喜欢

转载自blog.csdn.net/zms9110750/article/details/130767909