本文主要针对 GKarch 相关文章留作笔记,仅在原文基础上记录了自己的理解与摘抄部分片段。
遵循原作者的 CC 3.0 协议。
如果想要了解更加详细的文章信息内容,请访问下列地址进行学习。
同步构造基本分为四种,简单的阻塞方法、锁构造、信号构造、非阻塞同步构造。
Sleep()
与 Join()
方法。当一个线程被阻塞的时候,会立即出让(yields) CPU 时间片,不再消耗处理器时间。ThreadState
属性来确认某个线程是否被阻塞。Thread.Interrupt()
中断。Thread.Abort()
中止。信号构造与锁构造可以在某些条件被满足前阻塞线程。另外一种方法就是通过自旋来等待条件被满足。
自旋即通过一个循环不断检测条件,来伪造一个空忙状态。
虽然自旋会造成大量的处理器时间浪费,但是它可以避免上下文切换带来的额外开销。
一个标准的自旋结构如下列代码。
// 单纯的自旋
while(!proceed);
// 阻塞 + 自旋
while(!proceed) Thread.Sleep(10);
排它锁的作用是为了保证线程安全,如下列代码。如果 Go()
方法被两个线程同时执行,则可能某个线程在执行完 if
后,另一个线程已经将 V2
置为0,原先线程就可能造成除数不能为 0 的异常。
class Code1
{
static int V1 = 1,V2 = 1;
static void Go()
{
if(V2 != 0) Console.WriteLine(V1 / V2);
V2 = 0;
}
}
// 使用了排它锁的代码
class Code2
{
static int V1 = 1,V2 = 1;
static readonly object locker = new object();
static void Go()
{
lock(locker)
{
if(V2 != 0) Console.WriteLine(V1 / V2);
V2 = 0;
}
}
}
如果使用了 lock
语句快,则可以锁定一个同步对象,其他竞争锁的线程会被阻塞,直到锁被释放。
如果有多个线程竞争锁,则按照先到先得的队列进行排队,通过排它锁可以强制线程对锁保护的内容进行顺序访问。
在竞争锁时被阻塞的线程,其状态为 WaitSleepJoin
。
不同的同步结构技术的性能开销。
构造 | 用途 | 开销 |
---|---|---|
lock ( Monitor.Enter / Monitor.Exit ) |
确保同一时间只有一个线程可以访问资源或代码 | 20 ns |
Mutex | 确保同一时间只有一个线程可以访问资源或代码 | 1000 ns |
SemaphoreSlim | 确保只有不超过指定数量的线程可以并发访问资源或代码 | 200 ns |
Semaphore | 确保只有不超过指定数量的线程可以并发访问资源或代码 | 1000 ns |
ReaderWriterLockSlim | 允许多个读线程和一个写线程共存 | 40 ns |
ReaderWriterLock (已过时) |
允许多个读线程和一个写线程共存 | 100 ns |
lock
语句块实质上就是一个语法糖,其核心代码就是结合 try/finally
来调用 Monitor.Enter()
与 Monitor.Exit()
方法,并且如果在一个方法内直接调用 Monitor.Exit()
会直接抛出异常。
Monitor.Enter(locker);
try
{
if(V2 != 0) Console.Writeline(V1 / V2);
V2 = 0;
}
finally
{
Monitor.Exit(locker);
}
上述情况可能发生锁泄漏,因为在 Monitor.Enter()
与 try/finally
语句块之间如果发生了异常,会导致后续的 try/finally
语句块不被执行。造成无法获得锁,或者得到锁之后,无法释放造成锁泄漏。
解决锁泄漏的方式是,CLR 4.0 当中,对于 lock
语句的翻译则是通过一个 bool
类型的 lockTaken
进行解决。
bool lockTaken = false;
try
{
Monitor.Enter (locker, ref lockTaken);
// 用户代码 ...
}
finally
{
if(lockTaken)
{
Monitor.Exit(locker);
}
}
Monitor
还提供了 TryEnter()
方法,用于执行超时时间,如果超过时间没有获得到锁,则返回 false
。
需要访问任意可写的共享字段,下面代码展示了线程安全与非线程安全的代码。
class ThreadUnsafe
{
static int _x;
static void Increment() { _x++; }
static void Assign() { _x = 123; }
}
// 线程安全
class ThreadSafe
{
static readonly object _locker = new object();
static int _x;
static void Increment() { lock(_locker) _X++; }
static void Assign() { lock(_locker) _x++; }
}
lock(locker) { if(x != 0) y /= x; }
就可以说 x
与 y
是被原子访问的,因为这段代码无法被其他线程分割或者抢占。lock
锁内抛出异常,将会影响锁的原子性,这个时候就需要结合回滚机制来进行实现。lock
语句处被阻塞。死锁是当两个甚至多个线程所等待的资源都被对方占用的时候,它们都无法执行,就会产生了死锁。
一个标准的死锁代码如下,我们在 A 线程内部锁定了 locker1
与 locker2
,在主线程同同时也锁定了 locker2
与 locker1
。这个时候由于排他锁的特性,主线程与新开启的线程都会等待对方的锁被释放,造成死锁。
object locker1 = new object();
object locker2 = new object();
new Thread(() => {
lock(locker1)
{
Thread.Sleep(1000);
lock(loekcer2);
}
}).Start();
lock(locker2)
{
Thread.Sleep();
lock(locker1);
}
应该尽量较少对锁的使用,更多的依靠其他的同步构造进行处理。
WaitOne()
方法进行加锁,使用 ReleaseMutex()
来解锁。Mutex
对象会自动释放锁,所以可以结合 using
语句块进行使用。lock
慢约 50 倍。信号量具有一定容量,当容量满了之后和就会拒绝其他线程占用,当有一个线程释放资源之后,其他线程按先后顺序进入。
class Program
{
static void Main(string[] args)
{
var sem = new Semaphore();
for (int i = 1; i <= 5; i++) new Thread(sem.Enter).Start(i);
}
}
public class Semaphore
{
private readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(3);
public void Enter(object id)
{
Console.WriteLine($"Id 为 {id} 的线程想调用本方法。");
_semaphoreSlim.Wait();
Console.WriteLine($"Id 为 {id} 的线程已经进入方法。");
Thread.Sleep(1000 * (int)id);
Console.WriteLine($"Id 为 {id} 的线程正在离开方法。");
_semaphoreSlim.Release();
}
}
容量为 1 的信号量与 Mutex
和 lock
类似。
信号量是线程无关的,任何线程都可以调用 Release()
方法释放信号量。而 Mutex
与 lock
只有获得锁的线程才可以释放。
在 .NET 4.0 当中有一个轻量级的信号量 SemaphoreSlim
,但是不是跨进程的,开销只有 Semaphore
的四分之一。
一般在某些需要限流或者是要执行比较密集的磁盘 I/O 操作,这个时候可以使用信号量进行并发限制,这样可以改善程序整体的性能。
ContextBoundObject
类并且使用 Synchronization
特性。但是这种方法很容易造成死锁的情况,并且降低并发度。通过锁可以将不安全的代码转换为线程安全的代码,例如 BCL 提供的 List<T>
集合本身不是线程安全的,但是通过对一个集合实例的锁定,我们就可以进行线程安全的操作。下面的代码当中,我们直接使用 List<int>
集合自身来加锁,这里对集合进行遍历的操作也不是线程安全的,也需要加锁进行处理,另一种方式就是通过读写锁来实现避免长时间锁定。
class Program
{
static void Main(string[] args)
{
var bcl = new BCLThreadSafe();
for (int i = 0; i < 10; i++)
{
new Thread(bcl.AddItem).Start();
}
}
}
public class BCLThreadSafe
{
private readonly List<int> _innerList = new List<int>();
public void AddItem()
{
lock (_innerList)
{
_innerList.Add(_innerList.Count);
}
var sb = new StringBuilder();
lock (_innerList)
{
foreach (var item in _innerList)
{
sb.Append(item).Append(',');
}
}
Console.WriteLine(sb.ToString().TrimEnd(','));
}
}
即便 List<T>
集合是线程安全的,如果我们需要使用以下代码增加一个新的数据到集合当中。也会由于在执行 if
之后,其他线程抢占修改了 _list
集合,增加了一个相同的类目。在这个时候,对 _list
集合的添加操作就是存在问题的。
if(!_list.Contains(newItem)) _list.Add(newItem);
在高并发的环境下,对集合的访问加锁可能产生大量阻塞,所以进行类似操作的时候建议使用线程安全的队列、栈、字典。
针对于静态成员,BCL 的所有类型的静态成员都实现了线程安全,所以开发人员在开发基础类型或者框架的时候,应该保证静态成员的线程安全。
大部分 BCL 类型的只读访问都是线程安全的,开发人员在设计类基础类型或者框架的时候也应该遵循这个规则。
服务端经常需要使用到多线程处理客户请求,也就意味着必须考虑线程安全。但一般来说服务端类都是无状态的,或者为每个请求创建新的对象实例,很少存在有交互的点。
以缓存为例,假设对一个用户表使用了静态的字典实例进行缓存,那么就存在线程安全的问题。下列代码在读取与更新锁的时候,使用了排它锁进行加锁处理。但是会存在两个线程同时访问 GetUser()
方法的时候,都传递了未缓存过得数据的 id
,这个时候就会去查询两次数据库。虽然可以通过对整个 GetUser()
加锁,但是这样设计的话,都会在 QueryUser()
进行查询的时候,整个获得用户信息的方法都被阻塞。
static class UserCache
{
static Dictionary<int,User> _users = new Dictionary<int,User>();
internal static User GetUser(int id)
{
User u = null;
lock(_users)
{
if(_users.TryGetValue(id,out u))
{
return u;
}
}
// 从数据库查询用户数据
u = QueryUser(id);
lock(_users)_users[id] = u;
return u;
}
}
富客户端程序一般都是基于 DependencyObject
(WPF) 与 Control
(Windows Forms),它们都具备线程亲和性,即只有创建他们的线程才能够访问其成员。
作用就是访问 UI 对象并不需要加锁,坏处则是如果要跨线程调用 UI 控件则需要一些比较繁琐的步骤。
Dispatcher
调用 Invoke()
或 BeginInvoke()
。Control()
对象的 Invoke()
或 BeginInvoke()
。Invoke()
与 BeginInvoke()
都接收一个委托以便代替工作线程需要在 UI 线程执行的操作。前者是同步方法,在委托执行完成之前,都处于阻塞状态。后者是异步方法,调用方立刻返回。
// WPF DEMO
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
new Thread(Work).Start();
}
private void Work()
{
Thread.Sleep(5000); // 阻塞当前线程 5s 模拟耗时任务
UpdateMessage("new msg");
}
private void UpdateMessage(string msg)
{
var action = () => txtMessage.Text = msg;
Dispatcher.Invoke(action);
}
}
// Windows Forms DEMO
public partial class FormClass : Form
{
// ... 其他代码
private void UpdateMessage(string msg)
{
var action = () => txtMessage.Text = msg;
this.Invoke(action);
}
// ... 其他代码
}
事件等待句柄的作用是用于进行信号同步。
信号同步即一个线程进行等待,直到其接受到其他线程通知的过程。
信号构造的开销比较。
构造 | 用途 | 开销 |
---|---|---|
AutoResetEvent | 使线程在接收到其他线程信号时解除阻塞一次。 | 1000 ns |
ManualResetEvent | 使线程在接收到其他线程信号时解除阻塞,并不继续 阻塞,直到其复位。 |
1000 ns |
ManualResetEventSlim | 使线程在接收到其他线程信号时解除阻塞,并不继续 阻塞,直到其复位。 |
40 ns |
CountdownEvent | 使线程在收到预订数量的信号时,解除阻塞。 | 40 ns |
Barrier | 实现线程执行屏障。 | 80 ns |
Wait 和 Pulse | 使线程阻塞,直到自定义条件被满足。 | Pulse/120 ns |
AutoResetEvent
的原理类似于验票闸机,在闸机处调用 WaitOne()
方法,线程就会被阻塞。插入票的动作就类似于调用 Set()
方法打开闸机。任何能够访问这个 AutoResetEvent
的非阻塞线程都可以调用 Set()
方法来放行一个被阻塞的线程。
AutoResetEvent
是基于 EventWaitHandle
进行构造的,有两种方法可以创建 AutoResetEvent
对象。第一种即通过其构造方法 var auto = new AutoResetEvent (false);
,第二种则是通过 EventWaitHandle
传递事件类型,var auto = new EventWaitHandle (false, EventResetMode.AutoReset);
。这里如果传递的是 false
则会在创建后立即调用 Set()
方法。
class Program
{
public static readonly EventWaitHandle WaitHandle = new AutoResetEvent(false);
static void Main(string[] args)
{
var testClass = new AutoResetEventTest();
new Thread(testClass.Waiter).Start();
// 主线程等待 1 秒再发送信号唤醒
Thread.Sleep(1000);
WaitHandle.Set();
}
}
public class AutoResetEventTest
{
public void Waiter()
{
Console.WriteLine("线程开始等待...");
// 如果传入了超时时间,超时则返回 false。
Program.WaitHandle.WaitOne();
Console.WriteLine("接受到了通知,进入闸机。");
}
}
如果没有线程等待的时候调用 Set()
方法,则等待句柄会保持初始状态,直到有线程调用了 WaitOne()
方法。
为等待句柄调用 Reset()
方法可以关闭闸机,这个方法不会被阻塞。
可以调用 Dispose()
方法来销毁等待句柄,或者直接丢弃,等待 GC 进行回收。
如果主线程需要向工作线程连续发送 3 个信号并结束线程,则可以通过双向信号进行实现,其步骤大体如下。
null
,工作线程进行退出。 static void Main(string[] args)
{
var testObj = new MultiAutoResetEventTest();
new Thread(testObj.WorkThread).Start();
MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne();
lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString();
MultiAutoResetEventTest.WaitHandle_WorkThread.Set();
MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne();
lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString();
MultiAutoResetEventTest.WaitHandle_WorkThread.Set();
MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne();
lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = DateTime.Now.ToFileTimeUtc().ToString();
MultiAutoResetEventTest.WaitHandle_WorkThread.Set();
MultiAutoResetEventTest.WaitHandle_MainThread.WaitOne();
lock (MultiAutoResetEventTest.Locker) MultiAutoResetEventTest.Message = null;
MultiAutoResetEventTest.WaitHandle_WorkThread.Set();
}
}
public class MultiAutoResetEventTest
{
public static readonly EventWaitHandle WaitHandle_MainThread = new AutoResetEvent(false);
public static readonly EventWaitHandle WaitHandle_WorkThread = new AutoResetEvent(false);
public static string Message = string.Empty;
public static readonly object Locker = new object();
public void WorkThread()
{
while (true)
{
WaitHandle_MainThread.Set();
WaitHandle_WorkThread.WaitOne();
lock (Locker)
{
if (Message == null) return;
Console.WriteLine($"收到主线程的消息,内容为: {Message}");
}
}
}
}
生产消费者队列的构成如下所描述的一致。
生产/消费者队列可以精确控制工作线程的数量,CLR 的线程池就是一种生产/消费者队列。
结合 AutoResetEvent
事件等待句柄,我们可以很方便地实现一个生产/消费者队列。
class Program
{
static void Main(string[] args)
{
using (var queue = new ProducerConsumerQueue())
{
queue.EnqueueTask("Hello");
for (int i = 0; i < 10; i++)
{
queue.EnqueueTask($"{i}");
}
queue.EnqueueTask("End");
}
}
}
public class ProducerConsumerQueue : IDisposable
{
private readonly EventWaitHandle _waitHandle = new AutoResetEvent(false);
private readonly object _locker = new object();
private readonly Queue<string> _taskQueue = new Queue<string>();
private readonly Thread _workThread;
public ProducerConsumerQueue()
{
_workThread = new Thread(Work);
_workThread.Start();
}
public void EnqueueTask(string task)
{
// 向队列当中插入任务,加锁保证线程安全
lock (_locker)
{
_taskQueue.Enqueue(task);
}
// 通知工作线程开始干活
_waitHandle.Set();
}
private void Work()
{
while (true)
{
string task = null;
lock (_locker)
{
if (_taskQueue.Count > 0)
{
task = _taskQueue.Dequeue();
if (task == null) return;
}
}
if (task != null)
{
Thread.Sleep(100);
Console.WriteLine($"正在处理任务 {task}");
}
else
{
// 如果任务等于空则阻塞线程,等待心的工作项
_waitHandle.WaitOne();
}
}
}
public void Dispose()
{
// 优雅退出
EnqueueTask(null);
_workThread.Join();
_waitHandle.Close();
}
}
.NET 4.0 以后提供了一个 BlockingCollection<T>
类型实现了生产/消费者队列。
与 AutoResetEvent
类似,但在调用 Set()
方法的时候打开门,是可以允许任意数量的线程在调用 WaitOne()
后通过。(与 AutoResetEvent
每次只能通过 1 个不一样)
如果是在关闭状态下调用 WaitOne()
方法,线程会被阻塞,其余功能都与 AutoResetEvent
一致。
ManualResetEvent
的基类也是 EventWaitHandle
,通过以下两种方式均可构造。
var manual1 = new ManualResetEvent(false);
var manual2 = new EventWaitHandle(false, EventResetModel.ManualReset);
.NET 4.0 提供了性能更高的 ManualResetEventSliam
,但是不能够跨线程使用。
使用 CountdownEvent
可以指定一个计数器的值,用于表明需要等待的线程数量。
调用 Signal()
方法会将计数器自减 1 ,如果调用其 Wait()
则会阻塞计数到 0 ,通过 AddCount()
可以增加计数。
class Program
{
static void Main()
{
var test = new CountdownEventTest();
new Thread(test.Say).Start("Hello 1");
new Thread(test.Say).Start("Hello 2");
new Thread(test.Say).Start("Hello 3");
test.CountdownEvent.Wait();
Console.WriteLine("所有线程执行完成...");
}
}
public class CountdownEventTest
{
public readonly CountdownEvent CountdownEvent = new CountdownEvent(3);
public void Say(object info)
{
Thread.Sleep(1000);
Console.WriteLine(info);
CountdownEvent.Signal();
}
}
当计数为 0 的时候,无法通过 AddCount()
增加计数,只能调用 Reset()
进行复位。
除了手动开启线程之外,事件等待句柄也支持通过线程池来运行工作任务。
通过 ThreadPool.RegisterWaitForSingleObject()
方法可以减少资源消耗,当需要执行的委托处于等待状态的时候,不会浪费线程资源。
class Program
{
static void Main()
{
var test = new ThreadPoolTest();
test.Test();
}
}
public class ThreadPoolTest
{
private readonly EventWaitHandle _waitHandle = new ManualResetEvent(false);
public void Test()
{
RegisteredWaitHandle regHandle = ThreadPool.RegisterWaitForSingleObject(_waitHandle, Work, "OJBK", -1, true);
Thread.Sleep(1000);
_waitHandle.Set();
Console.ReadLine();
regHandle.Unregister(_waitHandle);
}
public void Work(object data,bool timeout)
{
Console.WriteLine($"正在执行任务 {data} .....");
}
}
上述代码如果通过传统的方式进行阻塞与信号发送, 那么有 1000 个请求 Work()
方法,就会造成大量服务线程阻塞,而 RegisterWaitForSingleObject
可以立即返回,不会浪费线程资源。
可以通过对 EventWaitHandle
类型构造函数的第三个参数传入标识,来获得跨进程的事件的等待句柄。
EventWaitHandle wh = new EventWaitHandle(false,EventResetMode.AutoReset,"AppName.Identity");
SynchronizationContext
类,而是 CLR 的自动锁机制。ContextBoundObject
基类并添加 Synchronization
特性即可让 CLR 自动加锁。Synchronization
特性的 reentrant
参数设置为 true
。则允许同步类是可被重入的,这就导致同步上下文被临时释放,会导致过度期间任何线程都可以自由调用原对象的任何方法。Synchronization
特性是直接作用于类,所以其所有方法都会带来可重入的问题。原文:https://www.cnblogs.com/myzony/p/10343205.html