C#基本线程同步0 概述
所谓同步,就是给多个线程规定一个执行的顺序(或称为时序),要求某个线程先执行完一段代码后,另一个线程才能开始执行。
第一种情况:多个线程访问同一个变量:
1.一个线程写,其它线程读:这种情况不存在同步问题,因为只有一个线程在改变内存中的变量,内存中的变量在任意时刻都有一个确定的值;
2.一个线程读,其它线程写:这种情况会存在同步问题,主要是多个线程在同时写入一个变量的时候,可能会发生一些难以察觉的错误,导致某些线程实际上并没有真正的写入变量;
3.几个线程写,其它线程读:情况同2。
多个线程同时向一个变量赋值,就会出现问题,这是为什么呢?
我们编程采用的是高级语言,这种语言是不能被计算机直接执行的,一条高级语言代码往往要编译为若干条机器代码,而一条机器代码,CPU也不一定是在一个CPU周期内就能完成的。计算机代码必须要按照一个“时序”,逐条执行。
举个例子,在内存中有一个整型变量number(4字节),那么计算++number(运算后赋值)就至少要分为如下几个步骤:
1. 寻址:由CPU的控制器找寻到number变量所在的地址;
2. 读取:将number变量所在的值从内存中读取到CPU寄存器中;
3. 运算:由CPU的算术逻辑运算器(ALU)对number值进行计算,将结果存储在寄存器中;
4. 保存:由CPU的控制器将寄存器中保存的结果重新存入number在内存中的地址。
这是最简单的时序,如果牵扯到CPU的高速缓存(CACHE),则情况就更为复杂了。
图1 CPU结构简图
在多线程环境下,当几个线程同时对number进行赋值操作时(假设number初始值为0),就有可能发生冲突:
当某个线程对number进行++操作并执行到步骤2(读取)时(0保存在CPU寄存器中),发生线程切换,该线程的所有寄存器状态被保存到内存后后,由另一个线程对number进行赋值操作。当另一个线程对number赋值完毕(假设将number赋值为10),切换回第一个线程,进行现场恢复,则在寄存器中保存的number值依然为0,该线程从步骤3继续执行指令,最终将1写入到number所在内存地址,number值最终为1,另一个线程对number赋值为10的操作表现为无效操作。
看一个例子:
[csharp]view plaincopy- usingSystem;
- usingSystem.Threading;
- namespaceEdu.Study.Multithreading.WriteValue{
- classPRogram{
- ///<summary>
- ///多个线程要访问的变量
- ///</summary>
- privatestaticintnumber=0;
- ///<summary>
- ///令线程随机休眠的随机数对象
- ///</summary>
- privatestaticRandomrandom=newRandom();
- ///<summary>
- ///线程入口方法,这里为了简化编程,使用了静态方法
- ///</summary>
- privatestaticvoidThreadWork(objectarg){
- //循环1000次,每次将number字段的值加1
- for(inti=0;i<1000;++i){
- //+=1操作比++操作需要更多的CPU指令,以增加出现错误的几率
- number+=1;
- //线程在10毫秒内随机休眠,以增加出现错误的几率
- Thread.Sleep(random.Next(10));
- }
- }
- ///<summary>
- ///主方法
- ///</summary>
- staticvoidMain(string[]args){
- do{
- //令number为0,重新给其赋值
- number=0;
- Threadt1=newThread(newParameterizedThreadStart(ThreadWork));
- Threadt2=newThread(newParameterizedThreadStart(ThreadWork));
- //启动两个线程访问number变量
- t1.Start();
- t2.Start();
- //等待线程退出,Timeout.Infinite表示无限等待
- while(t1.Join(Timeout.Infinite)&&t2.Join(Timeout.Infinite)){
- Console.WriteLine(number);
- break;
- }
- Console.WriteLine("请按按回车键重新测试,任意键退出程序......");
- }while(Console.ReadKey(false).Key==ConsoleKey.Enter);
- }
- }
- }
例子中,两个线程(t1和t2)同时访问number变量(初始值为0),对其进行1000次+1操作,在两个线程都结束后,在主线程显式number变量的最终值。可以看到,很经常的,最终显示的结果不是2000,而是1999或者更少。究其原因,就是发生了我们上面讲的问题:两个线程在进行赋值操作时,时序重叠了。
可以做实验,在CPU核心数越多的计算机上,上述代码出现问题的几率越小。这是因为多核心CPU可能会在每一个独立核心上各自运行一个线程,而CPU设计者针对这种多核心访问一个内存地址的情况,本身就设计了防范措施。
第二种情况:多个线程组成了生产者和消费者:
我们前面已经讲过,多线程并不能加快算法速度(多核心处理器除外),所以多线程的主要作用还是为了提高用户的响应,一般有两种方式:
- 将响应窗体事件操作和复杂的计算操作分别放在不同的线程中,这样当程序在进行复杂计算时不会阻塞到窗体事件的处理,从而提高用户操作响应;
- 对于为多用户服务的应用程序,可以一个独立线程为一个用户提供服务,这样用户之间不会相互影响,从而提高了用户操作的响应。
所以,线程之间很容易就形成了生产者/消费者模式,即一个线程的某部分代码必须要等待另一个线程计算出结果后才能继续运行。目前存在两种情况需要线程间同步执行:
- 多个线程向一个变量赋值或多线程改变同一对象属性;
- 某些线程等待另一些线程执行某些操作后才能继续执行。
1%20变量的原子操作
CPU有一套指令,可以在访问内存中的变量前,并将一段内存地址标记为“只读”,此时除过标志内存的那个线程外,其余线程来访问这块内存,都将发生阻塞,即必须等待前一个线程访问完毕后其它线程才能继续访问这块内存。
这种锁定的结果是:所有线程只能依次访问某个变量,而无法同时访问某个变量,从而解决了多线程访问变量的问题。
原子操作封装在Interlocked类中,以一系列静态方法提供:
- Add方法,对整型变量(4位、8位)进行原子的加法/减法操作,相当于n+=x或n-=x表达式的原子操作版本;
- Increment方法,对整形变量(4位、8位)进行原子的自加操作,相当于++n的原子操作版本;
- Decrement方法,对整型变量(4位、8位)进行原子的自减操作,相当于--n的原子操作版本;
- Exchange方法,对变量或对象引用进行原子的赋值操作;
- CompareExchange方法,对两个变量或对象引用进行比较,如果相同,则为其赋值。
例如:
Interlocked.Add方法演示
[csharp]view%20plaincopy- intn=0;
- //将n加1
- //执行完毕后n的值变为1,和返回值相同
- intx=Interlocked.Add(refn,1);
- //将n减1
- x=Interlocked.Add(refn,-1);
- Interlocked.Increment/Interlocked.Decrement方法演示
- intn=0;
- //对n进行自加操作
- //执行完毕后n的值变为1,和返回值相同
- intx=Interlocked.Increment(refn);
- //对n进行自减操作
- x=Interlocked.Decrement(refn);
- Interlocked.Exchange方法演示
- strings="Hello";
- //用另一个字符串对象"OK"为s赋值
- //操作完毕后s变量改变为引用到"OK"对象,返回"Hello"对象的引用
- stringold=Interlocked.Exchange(refs,"OK");
- Interloceked.CompareExchange方法演示
- strings="Hello";
- stringss=s;
- //首先用变量ss和s比较,如果相同,则用另一个字符串对象"OK"为s赋值
- //操作完毕后s变量改变为引用到"OK"对象,返回"Hello"对象的引用
- stringold=Interlocked.CompareExchange(refs,ss,"OK");
注意,原子操作中,要赋值的变量都是以引用方式传递参数的,这样才能在原子操作方法内部直接改变变量的值,才能完全避免非安全的赋值操作。
下面我们将前一节中出问题的代码做一些修改,修改其ThreadWork方法,在多线程下能够安全的操作同一个变量:
[csharp]view%20plaincopy