多线程同步技术全解析
1. 使用
lock
关键字
在多线程代码中,经常需要使用
Monitor
进行同步,而且
try/finally
块容易被遗忘。C#提供了
lock
关键字来处理这种锁定同步模式。以下是使用
lock
关键字的示例代码:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
const int _Total = int.MaxValue;
static long _Count = 0;
public static void Main()
{
Task task = Task.Factory.StartNew(Decrement);
// Increment
for (int i = 0; i < _Total; i++)
{
}
task.Wait();
Console.WriteLine("Count = {0}", _Count);
}
static void Decrement()
{
for (int i = 0; i < _Total; i++)
{
readonly static object _Sync = new object();
lock (_Sync)
{
_Count++;
}
lock (_Sync)
{
_Count--;
}
}
}
}
通过锁定访问
_Count
的代码部分(使用
lock
或
Monitor
),可以使
Main()
和
Decrement()
方法具有线程安全性,即可以从多个线程同时安全地调用这些方法。不过,同步会对性能产生影响,上述代码的执行时间比未使用同步的代码要长得多。
在进行对象设计时,一般的最佳实践是同步可变的静态状态,而不是实例数据。允许多个线程访问特定对象的程序员必须为该对象提供同步机制。
2. 选择锁定对象
无论是否显式使用
lock
关键字或
Monitor
类,程序员都必须仔细选择锁定对象。在前面的示例中,同步变量
_Sync
被声明为私有和只读的。声明为只读是为了确保在调用
Monitor.Enter()
和
Monitor.Exit()
之间值不会改变,这样可以确保进入和退出同步块之间的关联。声明为私有是为了防止类外部的同步块对同一对象实例进行同步,从而导致代码阻塞。
如果数据是公共的,同步对象可以是公共的,但这会增加避免死锁的难度。对于公共数据,最好将同步完全放在类外部,让调用代码使用自己的同步对象进行锁定。
需要注意的是,同步对象不能是值类型。如果对值类型使用
lock
关键字,编译器会报告错误。
3. 避免锁定
this
、
typeof(type)
和字符串
常见的做法是对类中的实例数据使用
this
关键字进行锁定,对静态数据使用
typeof(type)
获取的类型实例进行锁定。但这种做法存在问题,因为
this
或
typeof(type)
指向的同步目标可能会参与到另一个不相关代码块中创建的同步块的同步目标中,导致两个不同的同步块可能会相互阻塞,从而产生性能影响甚至死锁。因此,最好定义一个私有、只读的字段进行锁定。
另外,由于字符串驻留的原因,应避免锁定字符串。如果相同的字符串常量出现在多个位置,很可能所有位置都引用同一个实例,这会使锁定的范围比预期的大。
4. 避免使用
MethodImplAttribute
进行同步
.NET 1.0
引入的
MethodImplAttribute
与
MethodImplOptions.Synchronized
方法结合使用时,会将方法标记为同步,使得同一时间只有一个线程可以执行该方法。但这种实现方式会导致同一类中所有使用相同属性和枚举参数修饰的方法都被同步,而且由于是基于
this
或类型进行同步,会存在与
lock(this)
相同的问题,因此应避免使用该属性。
5. 将字段声明为
volatile
编译器和/或CPU有时会对代码进行优化,可能会改变指令的执行顺序或优化掉某些指令。在单线程中,这种优化是无害的,但在多线程中,可能会导致对同一字段的读写操作顺序相对于另一个线程的访问发生改变。为了稳定这种情况,可以使用
volatile
关键字声明字段,该关键字会强制对
volatile
字段的所有读写操作都在代码指定的位置执行,而不是在优化产生的其他位置执行。
6. 使用
System.Threading.Interlocked
类
前面描述的互斥模式为处理进程(应用程序域)内的同步提供了基本工具,但使用
System.Threading.Monitor
进行同步是一个相对昂贵的操作。处理器直接支持的另一种替代解决方案针对特定的同步模式。以下是使用
System.Threading.Interlocked
类的示例代码:
class SynchronizationUsingInterlocked
{
private static object _Data;
// Initialize data if not yet assigned.
static void Initialize(object newValue)
{
// If _Data is null then set it to newValue.
Interlocked.CompareExchange(
ref _Data, newValue, null);
}
// ...
}
Interlocked
类支持的其他同步方法如下表所示:
|方法签名|描述|
|–|–|
|
public static T CompareExchange<T>(T location, T value, T comparand );
|检查
location
中的值是否等于
comparand
。如果相等,则将
location
设置为
value
,并返回
location
中原来存储的数据。|
|
public static T Exchange<T>(T location, T value );
|将
location
赋值为
value
,并返回之前的值。|
|
public static int Decrement( ref int location);
|将
location
的值减1,等效于
--
运算符,但
Decrement()
是线程安全的。|
|
public static int Increment( ref int location);
|将
location
的值加1,等效于
++
运算符,但
Increment()
是线程安全的。|
|
public static int Add( ref int location, int value);
|将
value
加到
location
上,并将结果赋值给
location
,等效于
+=
运算符。|
|
public static long Read( ref long location);
|以单个原子操作返回一个64位的值。|
需要注意的是,如果一个线程使用非
Interlocked
方法访问
location
,则这两个访问将无法正确同步。
7. 多线程事件通知
开发人员在触发事件时常常会忽略同步问题。不安全的线程代码发布事件的示例如下:
// Not thread-safe
{
// Call subscribers
OnTemperatureChanged(
this, new TemperatureEventArgs(value) );
}
这段代码在没有竞争条件的情况下是有效的,但由于代码不是原子操作,多个线程可能会引入竞争条件。为了使代码具有线程安全性,应该先复制事件委托,检查副本是否为
null
,然后触发副本,示例代码如下:
// ...
TemperatureChangedHandler localOnChange =
OnTemperatureChanged;
if(localOnChanged != null)
{
// Call subscribers
localOnChanged(
this, new TemperatureEventArgs(value) );
}
// ...
if(OnTemperatureChanged != null)
8. 同步设计最佳实践
8.1 避免死锁
引入同步机制会带来死锁的潜在风险。死锁发生在两个或多个线程等待对方释放同步锁的情况下。要发生死锁,必须满足四个基本条件:
1. 互斥:一个线程(
ThreadA
)独占一个资源,使得其他线程(
ThreadB
)无法获取该资源。
2. 持有并等待:一个具有互斥资源的线程(
ThreadA
)正在等待获取另一个线程(
ThreadB
)持有的资源。
3. 无抢占:一个线程(
ThreadA
)持有的资源不能被强制移除,需要该线程自己释放锁定的资源。
4. 循环等待条件:两个或多个线程形成一个循环链,它们锁定相同的两个或多个资源,并且每个线程都等待链中下一个线程持有的资源。
只要移除其中任何一个条件,就可以防止死锁。为了避免死锁,开发人员应确保多个锁的获取顺序始终相同,并且避免使用不可重入的锁。
8.2 何时提供同步
所有静态数据都应该是线程安全的,因此需要对可变的静态数据进行同步。通常,程序员应该声明私有静态变量,并提供公共方法来修改数据,这些方法应该在内部处理同步。
相比之下,实例状态通常不需要包含同步。同步可能会显著降低性能,并增加锁竞争或死锁的可能性。除了专门为多线程访问设计的类之外,跨多个线程共享对象的程序员应该自行处理共享数据的同步。
8.3 避免不必要的锁定
在不影响数据完整性的前提下,程序员应尽可能避免不必要的同步。例如,在线程之间使用不可变类型,避免对线程安全的操作(如简单的
int
读写操作)进行锁定。
9. 更多同步类型
9.1
System.Threading.Mutex
System.Threading.Mutex
在概念上与
System.Threading.Monitor
类类似,但
lock
关键字不使用它,并且
Mutex
可以命名,支持跨多个进程的同步。可以使用
Mutex
类同步对文件或其他跨进程资源的访问。
以下是使用
Mutex
创建单实例应用程序的示例代码:
using System;
using System.Threading;
using System.Reflection;
class Program
{
public static void Main()
{
// Indicates whether this is the first
// application instance
bool firstApplicationInstance;
// Obtain the mutex name from the full
// assembly name.
string mutexName =
Assembly.GetEntryAssembly().FullName;
using( Mutex mutex = new Mutex(false, mutexName,
out firstApplicationInstance) )
{
if(!firstApplicationInstance)
{
Console.WriteLine(
"This application is already running.");
}
else
{
Console.WriteLine("ENTER to shutdown");
Console.ReadLine();
}
}
}
}
Mutex
派生自
System.Threading.WaitHandle
,因此包含
WaitAll()
、
WaitAny()
和
SignalAndWait()
方法,允许自动获取多个锁。
9.2
WaitHandle
WaitHandle
是
Mutex
、
EventWaitHandle
和
Semaphore
同步类使用的基本同步类。其关键方法是
WaitOne()
,该方法会阻塞执行,直到
WaitHandle
实例被发出信号或设置。
WaitOne()
方法有多个重载,支持无限期等待、毫秒级定时等待和
TimeSpan
等待。
此外,
WaitHandle
还有两个关键的静态成员
WaitAll()
和
WaitAny()
,它们也支持超时,并接受一个
WaitHandle
数组,以便对集合中的任何信号做出响应。需要注意的是,
WaitHandle
包含一个实现了
IDisposable
接口的句柄,因此在不再需要时应进行释放。
9.3 重置事件:
ManualResetEvent
和
ManualResetEventSlim
重置事件是一种控制线程中特定指令相对于另一个线程中指令执行时间不确定性的方法。重置事件与C#委托和事件无关,它可以强制代码等待另一个线程的执行,直到该线程发出信号。
重置事件类型包括
System.Threading.ManualResetEvent
和
.NET Framework 4
引入的轻量级版本
System.Threading.ManualResetEventSlim
。其关键方法是
Set()
和
Wait()
(在
ManualResetEvent
中称为
WaitOne()
)。调用
Wait()
方法会使线程阻塞,直到另一个线程调用
Set()
方法或等待超时。
以下是使用
ManualResetEventSlim
的示例代码:
using System;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
static ManualResetEventSlim MainSignaledResetEvent;
static ManualResetEventSlim DoWorkSignaledResetEvent;
public static void DoWork()
{
Console.WriteLine("DoWork() started....");
Console.WriteLine("DoWork() ending....");
}
public static void Main()
{
using(MainSignaledResetEvent =
new ManualResetEventSlim())
using (DoWorkSignaledResetEvent =
new ManualResetEventSlim())
{
Console.WriteLine(
"Application started....");
Console.WriteLine("Starting task....");
Task task = Task.Factory.StartNew(DoWork);
// Block until DoWork() has started.
DoWorkSignaledResetEvent.Wait();
Console.WriteLine("Thread executing...");
MainSignaledResetEvent.Set();
task.Wait();
Console.WriteLine("Thread completed");
Console.WriteLine(
"Application shutting down....");
}
}
}
ManualResetEventSlim
和
ManualResetEvent
的区别在于,后者默认使用内核同步,而前者经过优化,尽量避免访问内核。因此,除非需要等待多个事件或跨进程同步,否则一般使用
ManualResetEventSlim
。
9.4 高级主题:优先选择
ManualResetEvent
和信号量而不是
AutoResetEvent
System.Threading.AutoResetEvent
允许一个线程通过调用
Set()
方法向另一个线程发出信号,但它只会解除一个线程的
Wait()
调用的阻塞,因为第一个线程通过自动重置门后,门会再次锁定。这种事件类型容易导致生产者线程的迭代次数多于消费者线程的错误。因此,通常建议优先使用
Monitor
的
Wait()/Pulse()
模式或信号量。
与
AutoResetEvent
不同,
ManualResetEvent
在显式调用
Reset()
方法之前不会返回到未发出信号的状态。
9.5 信号量/
SemaphoreSlim
和
CountdownEvent
Semaphore
和
SemaphoreSlim
与
ManualResetEvent
和
ManualResetEventSlim
具有相同的性能差异。与
ManualResetEvent/ManualResetEventSlim
提供的要么打开要么关闭的锁不同,信号量只限制同时进入临界区的调用。信号量本质上会对资源池进行计数,当计数达到零时,会阻止对资源池的进一步访问,直到其中一个资源被返回。
CountdownEvent
与信号量类似,但实现的是相反的同步。它允许在计数达到零时才进行访问。例如,在并行下载大量股票报价的操作中,只有当所有报价都下载完成后,特定的搜索算法才能执行,这时可以使用
CountdownEvent
进行同步。
SemaphoreSlim
和
CountdownEvent
是
.NET Framework 4
引入的。
9.6 并发集合类
.NET Framework 4
引入的并发集合类专门设计为包含内置的同步代码,以便支持多个线程同时访问而无需担心竞争条件。并发集合类列表如下:
|集合类|描述|
|–|–|
|
BlockingCollection<T>
|提供一个阻塞集合,支持生产者/消费者场景,生产者将数据写入集合,消费者从集合中读取数据。该类提供了一个通用的集合类型,可同步添加和删除操作,而无需关心后端存储(如队列、栈、列表等)。
BlockingCollection<T>
为实现
IProducerConsumerCollection<T>
接口的集合提供阻塞和边界支持。|
|
*ConcurrentBag<T>
|一个线程安全的无序
T
类型对象集合。|
|
ConcurrentDictionary<TKey, TValue>
|一个线程安全的字典,包含键值对。|
|
*ConcurrentQueue<T>
|一个线程安全的队列,支持先进先出(FIFO)语义的
T
类型对象。|
|
*ConcurrentStack<T>
|一个线程安全的栈,支持后进先出(FILO)语义的
T
类型对象。|
通过合理使用这些同步技术和集合类,可以有效地处理多线程编程中的各种同步问题,提高程序的性能和稳定性。
多线程同步技术全解析
10. 多线程同步技术总结与应用场景分析
在多线程编程中,选择合适的同步技术对于程序的性能、稳定性和可维护性至关重要。下面我们将对前面介绍的各种同步技术进行总结,并分析它们的适用场景。
10.1 同步技术总结
| 同步技术 | 特点 | 适用场景 |
|---|---|---|
lock
关键字
|
使用简单,基于
Monitor
类,可实现基本的互斥同步
| 需要对共享资源进行简单互斥访问的场景,如多个线程对同一变量进行读写操作 |
System.Threading.Interlocked
类
| 提供原子操作,性能较高 | 对整数类型的简单增减操作,如计数器、标志位的更新 |
System.Threading.Mutex
| 可跨进程同步,支持命名 | 需要跨多个进程对共享资源进行同步的场景,如限制应用程序只能单实例运行 |
WaitHandle
及其派生类(
ManualResetEvent
、
ManualResetEventSlim
、
Semaphore
、
CountdownEvent
等)
| 提供多种同步机制,可控制线程的执行顺序和资源访问 | 需要控制线程执行顺序、等待特定事件发生或限制并发访问资源数量的场景 |
并发集合类(
BlockingCollection<T>
、
ConcurrentBag<T>
、
ConcurrentDictionary<TKey, TValue>
、
ConcurrentQueue<T>
、
ConcurrentStack<T>
等)
| 内置同步代码,支持多线程同时访问 | 多个线程需要同时对集合进行读写操作的场景,如生产者 - 消费者模型 |
10.2 应用场景示例
下面通过一个具体的示例来展示如何根据不同的需求选择合适的同步技术。假设我们有一个多线程的文件下载系统,需要实现以下功能:
- 限制同时下载的文件数量。
- 确保多个线程对下载任务队列的安全访问。
- 等待所有文件下载完成后进行后续处理。
以下是实现该系统的示例代码:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
class FileDownloadSystem
{
// 限制同时下载的文件数量
private readonly SemaphoreSlim _downloadSemaphore;
// 下载任务队列
private readonly BlockingCollection<string> _downloadQueue;
// 用于等待所有下载任务完成
private readonly CountdownEvent _downloadCountdown;
public FileDownloadSystem(int maxConcurrentDownloads)
{
_downloadSemaphore = new SemaphoreSlim(maxConcurrentDownloads);
_downloadQueue = new BlockingCollection<string>();
_downloadCountdown = new CountdownEvent(0);
}
public void AddDownloadTask(string fileUrl)
{
_downloadQueue.Add(fileUrl);
_downloadCountdown.AddCount();
}
public void StartDownload()
{
Task.Run(() =>
{
foreach (var fileUrl in _downloadQueue.GetConsumingEnumerable())
{
_downloadSemaphore.Wait();
Task.Run(async () =>
{
try
{
await DownloadFile(fileUrl);
}
finally
{
_downloadSemaphore.Release();
_downloadCountdown.Signal();
}
});
}
});
}
public void WaitForAllDownloads()
{
_downloadCountdown.Wait();
}
private async Task DownloadFile(string fileUrl)
{
// 模拟文件下载
Console.WriteLine($"Downloading {fileUrl}...");
await Task.Delay(1000);
Console.WriteLine($"Downloaded {fileUrl}");
}
}
class Program
{
static void Main()
{
var downloadSystem = new FileDownloadSystem(3);
// 添加下载任务
downloadSystem.AddDownloadTask("file1.txt");
downloadSystem.AddDownloadTask("file2.txt");
downloadSystem.AddDownloadTask("file3.txt");
downloadSystem.AddDownloadTask("file4.txt");
downloadSystem.AddDownloadTask("file5.txt");
// 开始下载
downloadSystem.StartDownload();
// 等待所有下载任务完成
downloadSystem.WaitForAllDownloads();
Console.WriteLine("All downloads completed.");
}
}
在这个示例中,我们使用了
SemaphoreSlim
来限制同时下载的文件数量,使用
BlockingCollection<string>
来实现下载任务队列的线程安全访问,使用
CountdownEvent
来等待所有下载任务完成。
11. 多线程同步的性能优化
在多线程编程中,同步操作往往会带来一定的性能开销。因此,在保证程序正确性的前提下,需要对同步操作进行性能优化。以下是一些常见的性能优化策略:
11.1 减少同步范围
在使用同步机制时,应尽量减少同步代码块的范围,只对必要的共享资源进行同步。这样可以减少线程之间的竞争,提高程序的并发性能。例如,在前面的
lock
关键字示例中,如果
_Count
的读写操作不是紧密相关的,可以将
lock
代码块拆分成更小的部分:
static void Decrement()
{
for (int i = 0; i < _Total; i++)
{
lock (_Sync)
{
_Count++;
}
// 其他非同步操作
lock (_Sync)
{
_Count--;
}
}
}
11.2 使用轻量级同步机制
对于一些简单的同步需求,可以使用轻量级的同步机制,如
System.Threading.Interlocked
类。该类提供的原子操作性能较高,避免了使用
lock
关键字或
Monitor
类带来的额外开销。例如,将计数器的增减操作改为使用
Interlocked
类:
static void Decrement()
{
for (int i = 0; i < _Total; i++)
{
Interlocked.Increment(ref _Count);
// 其他非同步操作
Interlocked.Decrement(ref _Count);
}
}
11.3 避免不必要的同步
在不影响程序正确性的前提下,尽量避免不必要的同步操作。例如,使用不可变类型来减少对共享资源的修改,从而减少同步的需求。另外,对于一些线程安全的操作,如简单的只读操作,不需要进行同步。
12. 多线程同步的调试与错误处理
在多线程编程中,由于线程的并发执行和同步操作的复杂性,调试和错误处理变得更加困难。以下是一些调试和错误处理的建议:
12.1 调试技巧
- 使用调试工具 :利用调试器的多线程调试功能,如断点、线程窗口、调用堆栈等,来观察线程的执行状态和同步操作的执行顺序。
- 添加日志信息 :在关键的同步代码块中添加日志信息,记录线程的进入和退出时间、共享资源的状态变化等,以便在出现问题时进行排查。
- 模拟并发场景 :使用测试工具或编写测试代码,模拟多线程并发执行的场景,以便发现潜在的同步问题。
12.2 错误处理策略
- 捕获异常 :在同步代码块中捕获可能出现的异常,并进行适当的处理,避免异常导致程序崩溃或死锁。
-
释放资源
:在出现异常时,确保及时释放已经获取的同步资源,避免资源泄漏。例如,在使用
Mutex或WaitHandle时,使用try-finally块确保资源的正确释放:
Mutex mutex = new Mutex();
try
{
mutex.WaitOne();
// 同步代码块
}
catch (Exception ex)
{
// 异常处理
}
finally
{
mutex.ReleaseMutex();
}
- 避免死锁 :在设计同步机制时,遵循避免死锁的原则,如确保多个锁的获取顺序一致、避免使用不可重入的锁等。如果发生死锁,可以使用调试工具分析线程的状态,找出死锁的原因并进行修复。
13. 总结
多线程编程是现代软件开发中不可或缺的一部分,而同步技术是保证多线程程序正确性和性能的关键。通过合理选择和使用各种同步技术,如
lock
关键字、
System.Threading.Interlocked
类、
Mutex
、
WaitHandle
及其派生类、并发集合类等,可以有效地处理多线程编程中的各种同步问题。
同时,在多线程编程中还需要注意性能优化、调试和错误处理等方面的问题。减少同步范围、使用轻量级同步机制、避免不必要的同步可以提高程序的性能;使用调试工具、添加日志信息、模拟并发场景可以帮助我们发现和解决同步问题;捕获异常、释放资源、避免死锁可以提高程序的稳定性和可靠性。
希望通过本文的介绍,读者能够对多线程同步技术有更深入的理解,并在实际开发中灵活运用这些技术,编写出高效、稳定的多线程程序。
超级会员免费看
10万+

被折叠的 条评论
为什么被折叠?



