翼度科技»论坛 编程开发 .net 查看内容

编码技巧 --- 同步锁对象的选定

8

主题

8

帖子

24

积分

新手上路

Rank: 1

积分
24
引言

在C#中,让线程同步有两种方式:

  • 锁(lock、Monitor)
  • 信号量(EventWaitHandle、Semaphore、Mutex)
线程锁的原理,就是锁住一个资源,使得应用程序在此刻只有一个线程访问该资源。通俗地讲,就是让多线程变成单线程。在C#中,可以将被锁定的资源理解成 new 出来的普通CLR对象。
如何选定

既然需要锁定的资源就是C#中的一个对象,我们就该仔细思考,到底什么样的对象能够成为一个锁对象(也叫同步对象)?
那么选择同步对象的时候,应当始终注意以下几点:

  • 同步对象在需要同步的多个线程中是可见的同一个对象。
  • 在非静态方法中,静态变量不应作为同步对象。
  • 值类型对象不能作为同步对象。
  • 避免将字符串作为同步对象。
  • 降低同步对象的可见性。
原因分析

接下来就探讨一下这五种情况。
注意事项1:需要锁定的对象在多个线程中是可见的,而且是同一个对象。
“可见的”这是显而易见的,如果对象不可见,就不能被锁定。
“同一个对象”,这也很容易理解,如果锁定的不是同一个对象,那又如何来同步两个对象呢?
虽然理解起来简单,但不见得我们在这上面就不会犯错误。
我们模拟一个必须使用到锁的场景:在遍历一个集合的过程中,同时在另外一个线程中删除集合中的某项。
下面这个例子中,如果没有 lock 语句,将会抛出异常System.InvalidOperationException:“Collection was modified; enumeration operation may not execute.”
  1. public partial class Form1 : Form
  2. {
  3.     public Form1()
  4.     {
  5.         InitializeComponent();
  6.     }
  7.     AutoResetEvent autoResetEvent = new AutoRe
  8.     List<string> strings = new List<string>()
  9.     private void btn_StartThreads_Click(object
  10.     {
  11.         object syncObj = new object();
  12.         Thread t1 = new Thread(() =>
  13.         {
  14.             //确保等待t2开始之后才运行下面的代码
  15.             autoResetEvent.WaitOne();
  16.             lock (syncObj)
  17.             {
  18.                 foreach (var item in strings)
  19.                 {
  20.                     Thread.Sleep(1000);
  21.                 }
  22.             }
  23.         });
  24.         t1.IsBackground = false;
  25.         t1.Start();
  26.         Thread t2 = new Thread(() =>
  27.         {
  28.             autoResetEvent.Set();
  29.             Thread.Sleep(1000);
  30.             lock (syncObj)
  31.             {
  32.                 strings.RemoveAt(1);
  33.             }
  34.         });
  35.         t2.IsBackground = false;
  36.         t2.Start();
  37.     }
  38. }
复制代码
上述例子是 Winform 窗体应用程序,按钮的单击事件中演示该功能。对象 syncObj 对于线程 t1 和 t2 来说,在CLR中肯定是同一个对象。所以,上面的示例运行是没有问题的。
现在,我们将此示例重构。将实际的工作代码移到一个类型 SampleClass 中,该示例要在多个 SampleClass 实例间操作一个静态字段,如下所示:
  1. public partial class Form1 : Form
  2. {
  3.     public Form1()
  4.     {
  5.         InitializeComponent();
  6.     }
  7.     private void btn_StartThreads_Click(object sender, EventArgs e)
  8.     {
  9.         SampleClass sampleClass1 = new SampleClass();
  10.         SampleClass sampleClass2 = new SampleClass();
  11.         sampleClass1.StartT1();
  12.         sampleClass2.StartT2();
  13.     }
  14. }
  15. public class SampleClass
  16. {
  17.     public static AutoResetEvent autoResetEvent = new AutoResetEvent(false);
  18.     static List<string> strings = new List<string>() { "str1", "str2", "str3" };
  19.     object syncObj = new object();
  20.     public void StartT1()
  21.     {
  22.         Thread t1 = new Thread(() =>
  23.         {
  24.             //确保等待t2开始之后才运行下面的代码
  25.             autoResetEvent.WaitOne();
  26.             lock (syncObj)
  27.             {
  28.                 foreach (var item in strings)
  29.                 {
  30.                     Thread.Sleep(1000);
  31.                 }
  32.             }
  33.         });
  34.         t1.IsBackground = false;
  35.         t1.Start();
  36.     }
  37.     public void StartT2()
  38.     {
  39.         Thread t2 = new Thread(() =>
  40.         {
  41.             autoResetEvent.Set();
  42.             Thread.Sleep(1000);
  43.             lock (syncObj)
  44.             {
  45.                 strings.RemoveAt(1);
  46.             }
  47.         });
  48.         t2.IsBackground = false;
  49.         t2.Start();
  50.     }
  51. }
复制代码
该例子运行起来就会抛出异常System.InvalidOperationException:“Collection was modified; enumeration operation may not execute.”
查看类型 SampleClass 的方法 StartT1 和 StartT2 ,方法内部锁定的是 SampleClass 的实例变量 syncObj 。
实例变量意味着,每创建一个 SampleClass 的实例都会生成一个 syncObj 对象。
在本例中,调用者一共创建了两个 SampleClass 实例,继而分别调用:
  1. samplel.StartTl();
  2. sample2.StartT2();
复制代码
也就是说,以上代码锁定的是两个不同的 syncObj ,这等于完全没有达到两个线程锁定同一个对象的目的。
要修正以上错误,只要将 syncObj 变成 static 就可以了。
另外,思考一下 lock(this) ,我们同样不建议在代码中编写这样的代码。如果两个对象的实例分别执行了锁定的代码,实际锁定的也就会是两个对象,完全不能达到同步的目的。
第二个注意事项:在非静态方法中,静态变量不应作为同步对象。
上文说到,要修正第一个注意事项中的示例问题,需要将 syncObj 变成 static。这似乎和本注意事项有矛盾。事实上,第一个注意事项中的示例代码仅仅出于演示的目的,在实际应用中,我们非常不建议编写此类代码。
在编写多线程代码时,要遵循这样的一个原则:
类型的静态方法应当保证线程安全,非静态方法不需实现线程安全。
FCL中的绝大部分类都遵循了这个原则。
像上一个示例中,如果将 syncObj 变成 static,就相当于让非静态方法具备了线程安全性,这带来的一个问题是,如果应用程序中该类型存在多个实例,在遇到这个锁的时候,它们都会产生同步,而这可能不是开发者所愿意看到的。第二个注意事项实际也可以归纳到第一个注意事项中。
第三个注意事项:值类型对象不能作为同步对象。
值类型在传递到另一个线程的时候,会创建一个副本,这相当于每个线程锁定的也是两个对象。因此,值类型对象不能作为同步对象。
第四个注意事项:锁定字符串是完全没有必要的,而且相当危险。
这整个过程看上去和值类型正好相反。字符串在CLR中会被暂存到内存里,如果有两个变量被分配了相同内容的字符串,那么这两个引用会被指向同一块内存。所以,如果有两个地方同时使用了lock(“abc”) ,那么它们实际锁定的是同一个对象,这会导致整个应用程序被阻滞。
第五个注意事项:降低同步对象的可见性。
可见范围最广的一种同步对象是 typeof(SampleClass)。
typeof()方法所返回的结果(也就是类型的type)是SampleClass 的所有实例所共有的,即:所有实例的type都指向typeof方法的结果。
这样一来,如果我们 lock(typeof(SampleClass) ,当前应用程序中所有 SampleClass 的实例线程将会全部被同步。这样编码完全没有必要,而且这样的同步对象太开放了。
一般来说,同步对象也不应该是一个公共变量或属性。在FCL的早期版本中,一些常用的集合类型(如 ArrayList )提供了公共属性 SyncRoot ,让我们锁定以便进行一些线程安全的操作。
所以你一定会觉得我们刚才的结论不正确。其实不然,ArrayList 操作的大部分应用场景不涉及多线程同步,所以它的方法更多的是单线程应用场景。线程同步是一个非常耗时(低效)的操作。若 ArrayList 的所有非静态方法都要考虑线程安全,那么 ArrayList 完全可以将这个 SyncRoot 变成静态私有的。现在它将 SyncRoot 变为公开的,是让调用者自己去决定操作是否需要线程安全。
我们在编写代码时,除非有这样的要求,否则就应该始终考虑降低同步对象的可见性,将同步对象藏起来,只开放给自己或自己的子类就够了(需要开放给子类的情况其实也不多)。
本篇内容引用自
编写高质量代码:改善C#程序的157个建议 / 陆敏技著.一北京:机械工业出版社,2011.9

来源:https://www.cnblogs.com/pandefu/archive/2023/07/13/17536276.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

举报 回复 使用道具