改善C#程序的建议6:在线程同步中使用信号量

简介: 所谓线程同步,就是多个线程之间在某个对象上执行等待(也可理解为锁定该对象),直到该对象被解除锁定。C#中对象的类型分为引用类型和值类型。CLR在这两种类型上的等待是不一样的。我们可以简单的理解为在CLR中,值类型是不能被锁定的,也即:不能在一个值类型对象上执行等待。

所谓线程同步,就是多个线程之间在某个对象上执行等待(也可理解为锁定该对象),直到该对象被解除锁定。C#中对象的类型分为引用类型和值类型。CLR在这两种类型上的等待是不一样的。我们可以简单的理解为在CLR中,值类型是不能被锁定的,也即:不能在一个值类型对象上执行等待。而在引用类型上的等待机制,则分为两类:锁定和信号同步。

锁定,使用关键字lock和类型Monitor。两者没有实质区别,前者其实是后者的语法糖。这是最常用的同步技术;

本建议我们讨论的是信号同步。信号同步机制中涉及的类型都继承自抽象类WaitHandle,这些类型有EventWaitHandle(类型化为AutoResetEvent、ManualResetEvent)和Semaphore以及Mutex。见类图6-3:

clip_image002

图 同步功能类类图

EventWaitHandle(子类为AutoResetEvent、ManualResetEvent)和Semaphore以及Mutex都继承自WaitHandle,所以它们底层的原理是一致的,维护的都是一个系统内核句柄。不过我们仍需简单的区分下这三类类型。

EventWaitHandle,维护一个由内核产生的布尔类型对象(我们称之为“阻滞状态”),如果其值为false,那么在它上面等待的线程就阻塞。可以调用类型的Set方法将其值设置为true,解除阻塞。EventWaitHandle类型的两个子类AutoResetEvent和ManualResetEvent,它们的区别并不大,本建议接下来会针对它们阐述如何正确使用信号量。

Semaphore,维护一个由内核产生的整型变量,如果其值为0,则在它上面等待的线程就阻塞,其值大于0,就解除阻塞,同时,每解除阻塞一个线程,其值就减1。

EventWaitHandle和Semaphore提供的都是单应用程序域内的线程同步功能,Mutex则不同,它为我们提供了跨应用程序域阻塞和解除阻塞线程的能力。


1:使用信号机制提供线程同步的一个简单的例子

使用信号机制提供线程同步的一个简单的例子如下:

 
 
AutoResetEvent autoResetEvent = new AutoResetEvent( false );

private void buttonStartAThread_Click( object sender, EventArgs e)
{
Thread tWork
= new Thread(() =>
{
label1.Text
= " 线程启动... " + Environment.NewLine;
label1.Text
+= " 开始处理一些实际的工作 " + Environment.NewLine;
// 省略工作代码
label1.Text += " 我开始等待别的线程给我信号,才愿意继续下去 " + Environment.NewLine;
autoResetEvent.WaitOne();
label1.Text
+= " 我继续做一些工作,然后结束了! " ;
// 省略工作代码
});
tWork.IsBackground
= true ;
tWork.Start();
}

private void buttonSet_Click( object sender, EventArgs e)
{
// 给在autoResetEvent上等待的线程一个信号
autoResetEvent.Set();
}

这是一个简单的Winform窗体程序,其中一个按钮负责开启一个新的线程,还有一个按钮负责给刚开启的那个线程发送信号。现在详细解释这里面发生的事情。

 
 
AutoResetEvent autoResetEvent = new AutoResetEvent( false );

这段代码创建了一个同步类型对象autoResetEvent,它设置自己的默认阻滞状态是false。这意味着任何在它上面进行等待的线程将会被阻滞。所谓进行等待,就是在线程中应用:

 
 
autoResetEvent.WaitOne();

这说明tWork开始在autoResetEvent上等待任何其它地方给它的信号。信号来了,则tWork开始继续工作,否则就一直等着(即阻滞)。接下来我们看到在主线程中(本例中即UI线程,它相对线程tWork来说,就是一个“另外的线程”):

 
 
autoResetEvent.Set();

主线程通过上面这句代码负责向在autoResetEvent上等待的线程tWork上下文发送信号,即将tWork的阻滞状态设置为true。tWork接收到这个信号,开始继续工作。

这个例子相当简单,但是已经完整说明了信号机制的工作原理。


2:AutoResetEvent和ManualResetEvent的区别

AutoResetEvent和ManualResetEvent有这样的区别:前者在发送信号完毕后(即调用Set方法),自动将自己的阻滞状态设置为false,而后者需要进行手动设定。可以通过一个例子来说明这种区别:

 
 
AutoResetEvent autoResetEvent = new AutoResetEvent( false );

private void buttonStartAThread_Click( object sender, EventArgs e)
{
StartThread1();
StartThread2();
}

private void StartThread1()
{
Thread tWork1
= new Thread(() =>
{
label1.Text
= " 线程1启动... " + Environment.NewLine;
label1.Text
+= " 开始处理一些实际的工作 " + Environment.NewLine;
// 省略工作代码
label1.Text += " 我开始等待别的线程给我信号,才愿意继续下去 " + Environment.NewLine;
autoResetEvent.WaitOne();
label1.Text
+= " 我继续做一些工作,然后结束了! " ;
// 省略工作代码
});
tWork1.IsBackground
= true ;
tWork1.Start();
}

private void StartThread2()
{
Thread tWork2
= new Thread(() =>
{
label2.Text
= " 线程2启动... " + Environment.NewLine;
label2.Text
+= " 开始处理一些实际的工作 " + Environment.NewLine;
// 省略工作代码
label2.Text += " 我开始等待别的线程给我信号,才愿意继续下去 " + Environment.NewLine;
autoResetEvent.WaitOne();
label2.Text
+= " 我继续做一些工作,然后结束了! " ;
// 省略工作代码
});
tWork2.IsBackground
= true ;
tWork2.Start();
}

private void buttonSet_Click( object sender, EventArgs e)
{
// 给在autoResetEvent上等待的线程一个信号
autoResetEvent.Set();
}

这个例子的本意是要让新起的两个工作线程tWork1和tWork2都阻滞起来,直到收到主线程的信号再继续工作。结果程序运行的结果是,只有一个工作线程继续工作,另外一个工作线程则继续保持阻滞状态。我想原因大家都已经想到了。由于AutoResetEvent在发送信号完毕就在内核中自动将自己的状态设置回false了,所以另外一个工作线程相当于根本没有收到主线程的信号。

要修正这个问题,可以使用ManualResetEvent。大家可以换成ManualResetEvent试一下。


3:应用实例

最后,再举一个需要用到线程同步的实际例子:模拟网络通信。客户端在运行过程中,服务器每隔一段的时间会给客户端发送心跳数据。实际工作中服务器和客户端会是网络中两台不同的终端,在这个例子中我们进行了简化。工作线程tClient模拟客户端,主线程(UI线程)模拟服务器端。客户端每3秒检测是否收到服务器的心跳数据,如果没有心跳数据,则显示网络连接断开。代码如下:

 
 
AutoResetEvent autoResetEvent = new AutoResetEvent( false );

private void buttonStartAThread_Click( object sender, EventArgs e)
{
Thread tClient
= new Thread(() =>
{
while ( true )
{
// 等3秒,3秒没有信号,显示断开
// 有信号,则显示更新
bool re = autoResetEvent.WaitOne( 3000 );
if (re)
{
label1.Text
= string .Format( " 时间:{0},{1} " , DateTime.Now.ToString(), " 保持连接状态 " );
}
else
{
label1.Text
= string .Format( " 时间:{0},{1} " , DateTime.Now.ToString(), " 断开,需要重启 " );
}
}
});
tClient.IsBackground
= true ;
tClient.Start();
}

private void buttonSet_Click( object sender, EventArgs e)
{
// 模拟发送心跳数据
autoResetEvent.Set();
}


备注:由本问题带来一个Winform跨线程控件赋值和操作的问题。由于在本示例中不影响上面代码的运行,所以没有涉及,但是回复中有人提出来,所以提前简述一下Winform的线程模型:

在Winform框架中,有一个ISynchronizeInvoke接口,所有的UI元素(表现为Control)都继承了该接口。其中,接口中的InvokdRequired属性表示了当前线程是否是创建它的线程。接口中的Invoke和BeginInvoke方法负责将消息发送到消息队列中,这样,UI线程就能够正确处理它。

具体到代码中,对于夸线程控件赋值,可以采用下面的方法:

this.label1.BeginInvoke(new Action(()=>
{
this.label1.Text = "跨线程中赋值";
}));

之前的话题:

改善C#程序的建议5:引用类型赋值为null与加速垃圾回收

改善C#程序的建议4:C#中标准Dispose模式的实现

改善C#程序的建议3:在C#中选择正确的集合进行编码

改善C#程序的建议2:C#中dynamic的正确用法

改善C#程序的建议1:非用ICloneable不可的理由

Creative Commons License本文基于 Creative Commons Attribution 2.5 China Mainland License发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名 http://www.cnblogs.com/luminji(包含链接)。如您有任何疑问或者授权方面的协商,请给我留言。
目录
相关文章
|
4月前
|
C# 开发者
C# 9.0中的模块初始化器:程序启动的新控制点
【1月更文挑战第14天】本文介绍了C# 9.0中引入的新特性——模块初始化器(Module initializers)。模块初始化器允许开发者在程序集加载时执行特定代码,为类型初始化提供了更细粒度的控制。文章详细阐述了模块初始化器的语法、用途以及与传统类型初始化器的区别,并通过示例代码展示了如何在实际项目中应用这一新特性。
|
3月前
|
存储 安全 Java
程序与技术分享:C#值类型和引用类型的区别
程序与技术分享:C#值类型和引用类型的区别
29 0
|
7天前
|
C# 容器
C#中的命名空间与程序集管理
在C#编程中,`命名空间`和`程序集`是组织代码的关键概念,有助于提高代码的可维护性和复用性。本文从基础入手,详细解释了命名空间的逻辑组织方式及其基本语法,展示了如何使用`using`指令访问其他命名空间中的类型,并提供了常见问题的解决方案。接着介绍了程序集这一.NET框架的基本单位,包括其创建、引用及高级特性如强名称和延迟加载等。通过具体示例,展示了如何创建和使用自定义程序集,并提出了针对版本不匹配和性能问题的有效策略。理解并善用这些概念,能显著提升开发效率和代码质量。
22 4
|
13天前
|
Linux C# 开发者
Uno Platform 驱动的跨平台应用开发:从零开始的全方位资源指南与定制化学习路径规划,助您轻松上手并精通 C# 与 XAML 编程技巧,打造高效多端一致用户体验的移动与桌面应用程序
【9月更文挑战第8天】Uno Platform 的社区资源与学习路径推荐旨在为初学者和开发者提供全面指南,涵盖官方文档、GitHub 仓库及社区支持,助您掌握使用 C# 和 XAML 创建跨平台原生 UI 的技能。从官网入门教程到进阶技巧,再到活跃社区如 Discord,本指南带领您逐步深入了解 Uno Platform,并提供实用示例代码,帮助您在 Windows、iOS、Android、macOS、Linux 和 WebAssembly 等平台上高效开发。建议先熟悉 C# 和 XAML 基础,然后实践官方教程,研究 GitHub 示例项目,并积极参与社区讨论,不断提升技能。
31 2
|
27天前
|
缓存 NoSQL Redis
【Azure Redis 缓存】C#程序是否有对应的方式来优化并缩短由于 Redis 维护造成的不可访问的时间
【Azure Redis 缓存】C#程序是否有对应的方式来优化并缩短由于 Redis 维护造成的不可访问的时间
|
1月前
|
安全 C# 开发者
【C# 多线程编程陷阱揭秘】:小心!那些让你的程序瞬间崩溃的多线程数据同步异常问题,看完这篇你就能轻松应对!
【8月更文挑战第18天】多线程编程对现代软件开发至关重要,特别是在追求高性能和响应性方面。然而,它也带来了数据同步异常等挑战。本文通过一个简单的计数器示例展示了当多个线程无序地访问共享资源时可能出现的问题,并介绍了如何使用 `lock` 语句来确保线程安全。此外,还提到了其他同步工具如 `Monitor` 和 `Semaphore`,帮助开发者实现更高效的数据同步策略,以达到既保证数据一致性又维持良好性能的目标。
28 0
|
1月前
|
C#
WPF/C#:程序关闭的三种模式
WPF/C#:程序关闭的三种模式
30 0
|
3月前
|
开发框架 .NET 编译器
程序与技术分享:C#基础知识梳理系列三:C#类成员:常量、字段、属性
程序与技术分享:C#基础知识梳理系列三:C#类成员:常量、字段、属性
25 2
|
3月前
|
C#
WPF/C#:程序关闭的三种模式
WPF/C#:程序关闭的三种模式
47 3