一、并行编程
1、Parallel 类
Parallel类是System.Threading.Tasks命名空间中的一个重要类,它提供数据并行和任务并行的高级抽象。
For和ForEach
Parallel类下的For和ForEach对应着普通的循环和遍历(普通的for和foreach),但执行时会尝试在多个线程上同时处理循环迭代。
//For
Parallel.For(0, 10, i =>
{
Console.WriteLine($"我是:{i}。我的线程ID是:{Thread.CurrentThread.ManagedThreadId}");
});
//ForEach
List<string> items = new List<string> { "A", "B", "C", "D", "E" };
Parallel.ForEach(items, item =>
{
Console.WriteLine($"我是:{item}。我的线程ID是:{Thread.CurrentThread.ManagedThreadId}");
});
与普通For输出后的区别(右为普通For循环),我们可以看到Parallel类下For输出的顺序不是递增的,这是因为它是在不同的线程中执行所导致的。
Invoke
尽可能并行执行提供的每个操作,即方法调用。
Action action1 = () => Console.WriteLine("你好");
Action action2 = () => Console.WriteLine("不好");
Parallel.Invoke(action1, action2);
2、PLINQ
PLINQ为Parallel LINQ的缩写,在LINQ中允许你利用多核处理器并行处理数据,PLINQ会自动将查询拆分成多个部分,并在多个线程上并行执行这些部分。
AsParallel()
将顺序的LINQ查询转换为并行的查询,允许利用多核处理器并行处理数据集合,从而加速查询的执行,当调用该方法时,LINQ查询会转换为PLINQ查询。
int[] data = Enumerable.Range(0, 100).ToArray();
var query =from i in data.AsParallel()
where i%10==0
select i;
并行度
在默认的情况下,PLINQ会使用计算机上所有的处理器,使用WithDegreeOfParallelism用于指定用于并行查询执行的处理器的最大数量,即同时执行的任务数量。
int[] data = Enumerable.Range(0, 100).ToArray();
var query =from i in data.AsParallel().WithDegreeOfParallelism(3)
where i%10==0
select i;
排序
默认情况下,PLINQ 不保证输出顺序与输入顺序一致。如果需要保持顺序,可以使用AsOrdered()方法,但是调用该方法时,PLINQ 将尝试在并行处理的同时保留元素的顺序,这也就意味着性能可能会下降。
int[] data = Enumerable.Range(0, 100).ToArray();
var query =from i in data.AsParallel().AsOrdered()
where i%10==0
select i;
ForAll()
用于并行地遍历查询结果集,并对每个元素执行一个指定的操作,并行执行,可以充分利用多核处理器的性能优势,加快处理速度,但并不保证操作的执行顺序与原始数据集中的顺序一致。
query.ForAll(res =>
{
Console.WriteLine(res);
});
二、数据并发控制
1、lock(锁)
lock关键字用于确保当一个线程进入代码的临界区时,其他线程不会进入该临界区,这有助于防止多个线程同时访问同一资源,可能导致数据不一致以及数据被破坏的问题。(lock关键字通常与对象一起使用,该对象用作锁定的目标,通常称为“锁对象”。)
public class Account
{
private Object thisLock = new Object();
public void Withdraw()
{
// 这是一个临界区,只有一个线程可以进入
lock (thisLock)
{
try
{
//模拟工作
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"我是{i},我的线程ID:{Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(500);//模拟延迟
}
}
finally
{
Console.WriteLine($"线程{Thread.CurrentThread.ManagedThreadId}被释放了");
// 确保锁被释放,即使发生异常
}
}
}
}
当我们使用两个或者多个线程同时调用该方法时, 只会有一个线程拿到锁对象进入到临界区,其余的线程会形成阻塞态,一直等待该锁被释放然后进入到临界区(或者任务超时该线程摧毁)。
Account account = new Account();
//创建线程1任务
Task task1 = Task.Run(() => account.Withdraw());
//创建线程2任务
Task task2 = Task.Run(() => account.Withdraw());
//等待两个任务完成
Task.WaitAll(task1, task2);
2、Monitor类
Monitor类允许线程安全地访问共享资源,是System.Threading命名空间的一部分,并且提供了比lock更底层和更灵活的功能,防止多个线程同时访问某个代码段或资源导致数据不一致以及数据被破坏的问题。
public class Account
{
private Object thisLock = new Object();
public void Withdraw()
{
//获取锁
Monitor.Enter(thisLock);
try
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine($"我是{i},我的线程ID:{Thread.CurrentThread.ManagedThreadId}");
Thread.Sleep(500);
}
}
finally
{
Console.WriteLine($"线程{Thread.CurrentThread.ManagedThreadId}被释放了");
//释放锁
Monitor.Exit(thisLock);
// 确保锁被释放,即使发生异常
}
}
}
注意:一个线程获取到了锁,那么其他线程就会形成阻塞态,如果该线程的任务完成之后,没有释放锁,那么后面的线程就一直处于阻塞态的状态,形成死锁。
TryEnter
指定毫秒内获取锁,如果获取到锁则返回true,如果没有获取到锁,则返回false,该方法通常配合一些逻辑执行,任务完成后同样需要Exit来释放锁,否则会造成死锁。
bool MonitorBool = Monitor.TryEnter(thisLock,5000);
3、ReaderWriterLockSlim类
它允许多个读取者同时访问资源,但在写入时则独占资源,即不允许其他读取者或写入者同时访问(读共享写独享)。
1、基本读写锁
读锁(EnterReadLock和ExitReadLock):当多个线程需要同时读取共享资源时,可以使用读锁。多个线程可以同时持有读锁,这意味着它们可以同时读取共享资源,但在此期间,任何线程都不能获得写锁。
写锁(EnterWriteLock和ExitWriteLock):当线程需要修改共享资源时,它必须获得写锁。在写锁被持有的期间,其他线程既不能获得读锁也不能获得写锁,确保共享资源的单独访问。
public class Account
{
private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
private int sharedData = 0;
//写操作
public void WriteData(int value)
{
//获取写入锁
rwLock.EnterWriteLock();
try
{
//执行写入
sharedData = value;
Thread.Sleep(2000);//模拟延迟
}
finally
{
Console.WriteLine($"{sharedData}写入完成,我的线程ID:{Thread.CurrentThread.ManagedThreadId}");
//释放写入锁
rwLock.ExitWriteLock();
}
}
//读操作
public int ReadData()
{
//获取读取锁
rwLock.EnterReadLock();
try
{
Thread.Sleep(2000);//模拟延迟
return sharedData;
}
finally
{
Console.WriteLine($"读取完成,我的线程ID:{Thread.CurrentThread.ManagedThreadId}");
//释放读取锁
rwLock.ExitReadLock();
}
}
}
2、升级读写锁
升级读锁(EnterUpgradeableReadLock和ExitUpgradeableReadLock):当线程以可升级的方式获取读锁时,它表示该线程可能随后需要升级到写锁,在此期间,其他线程不能获得写锁,但可以获得读锁。
升级写锁(EnterWriteLock):当持有可升级的读锁的线程决定需要修改共享资源时,它可以调用 EnterWriteLock 方法来升级锁,而无需先释放读锁,这将阻塞其他所有尝试获取读锁或写锁的线程,直到写锁被释放。
public class Account
{
private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
private int sharedData = 0;
public void UpgradeableReadAndWriteData(int value)
{
//进入升级读取模式
rwLock.EnterUpgradeableReadLock();
try
{
// 执行读操作
int currentData = sharedData;
// 假设基于读取的数据,决定需要修改数据
if (currentData != value)
{
// 升级到写锁,而不释放读锁
rwLock.EnterWriteLock();
try
{
// 在写锁下,执行写操作
sharedData = value;
Console.WriteLine($"{sharedData}写入,我的线程ID是{Thread.CurrentThread.ManagedThreadId}");
}
finally
{
Console.WriteLine($"退出写锁,我的线程ID是{Thread.CurrentThread.ManagedThreadId}");
// 退出写锁,但保持读锁
rwLock.ExitWriteLock();
}
}
}
finally
{
Console.WriteLine($"退出升级读锁,我的线程ID是{Thread.CurrentThread.ManagedThreadId}");
// 退出可升级的读锁
rwLock.ExitUpgradeableReadLock();
}
}
}
4、Concurrent(并发集合)
BlockingCollection<T>
提供了一种线程安全的方式来在多个生产者线程和消费者线程之间共享数据。当集合为空时,尝试从中取数据的操作会被阻塞,直到有数据可用。同样地,当集合已满(如果设置了容量限制)时,尝试添加数据的操作也会被阻塞。
using System.Collections.Concurrent;
BlockingCollection<int> collection = new BlockingCollection<int>();
// 启动生产者任务
Task producerTask = Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("生产者生产数据: " + i);
collection.Add(i); // 将数据添加到集合中
Thread.Sleep(1000); // 模拟耗时操作
}
// 通知消费者没有更多的数据将要添加
collection.CompleteAdding();
});
// 启动消费者任务
Task consumerTask = Task.Run(() =>
{
foreach (var item in collection.GetConsumingEnumerable())
{
Console.WriteLine("消费者消费数据: " + item);
Thread.Sleep(500); // 模拟耗时操作
}
});
// 等待生产者和消费者任务完成
Task.WaitAll(producerTask, consumerTask);
常用类:
ConcurrentBag<T>
这是一个无序的线程安全集合,允许线程安全地添加和移除元素。
ConcurrentDictionary<TKey, TValue>
这是一个线程安全的字典,支持线程安全地添加、移除和访问键值对。
ConcurrentQueue<T>
这是一个线程安全的先进先出(FIFO)集合,支持线程安全地入队和出队操作。
ConcurrentStack<T>
这是一个线程安全的后进先出(LIFO)集合,支持线程安全地推入和弹出操作。
Partitioner<TSource>
用于将数据划分为多个分区,以便并行处理。这通常与TPL(Task Parallel Library)一起使用,以便在多个线程或任务上并行处理数据。
OrderablePartitioner<TSource>
这是一个特殊的分区器,它保留了元素的顺序,允许在并行处理的同时保持元素的顺序。
三、异步流
允许你以异步的方式处理数据流,当你需要在不阻塞线程或其他重要线程的情况下处理大量数据时使用。关键字为IAsyncEnumerable<T>,它允许你以异步的方式枚举数据序列,而不需要一次性加载所有数据到内存中。
public class Account
{
public async IAsyncEnumerable<int> GenerateAsyncStream()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(1000);
yield return i;
yield return i + 1;
}
}
}
IAsyncEnumerable<int> asyncStream = new Account().GenerateAsyncStream();
await foreach (int i in asyncStream)
{
Console.WriteLine(i);
}
常见的并发编程方法还有异步编程和多线程编程等。
四、并发的注意
使用并发编程的时候应该避免以下及其其他问题的出现,通常并发编程的错误都是因为多个线程同时访问和修改共享资源,或者由于线程之间的同步和协调不当导致的。
1、竞态条件
竞态条件发生在两个或多个线程同时访问共享资源,并且它们的访问顺序会影响程序的结果,在计算共享数据时,由于多个线程计算顺序的不同,导致最终计算的结果也不同导致的。
2、死锁
当两个或多个线程相互等待对方释放资源时,导致所有线程都无法继续执行,这通常是因为线程间的锁顺序不一致导致的。
3、活锁
当线程们都在忙于响应其他线程的动作,但无法完成它们自己的任务时导致的。
4、饥饿
当其他线程一直占用资源而得不到释放时,某些线程长时间得不到执行的机会而导致的。
5、数据不一致
当多个线程没有正确同步对共享数据的访问时,可能会导致数据的不一致状态,这可能是因为一个线程正在读取数据,而另一个线程同时修改了这些数据。