雁过请留痕...
代码改变世界

基元线程同步——基础,非阻塞同步(VolatileRead,VolatileWrite,volatile,Interlocked)

2012-08-25 11:22  xiashengwang  阅读(3996)  评论(0编辑  收藏  举报

一、基元用户模式和内核模式。

基元(Primitive):指代码中可以使用的最简单的构造。

有两种基元构造:用户模式(user-mode)和内核模式(kernel-mode)。

1,用户模式。

它是用CPU指令来协调线程,这种协调是在硬件中发生的,所以速度会快于内核模式。但是也意味着,Windows操作系统永远也检测不到一个线程在一个基元用户模式构造上阻塞了。由于在一个基元用户模式构造上阻塞的线程永远不认为已经阻塞,所以线程池不会创建新的线程来替换这种阻塞的线程。另外,这些CPU指令只是阻塞线程极短的时间。

缺点:只有Windows系统的内核才能停止一个线程的执行。用户模式中的线程可能会被系统抢占,但很快就会被再次调度。如果一个线程想获得资源又暂时取不到资源,它会一直在用户模式中运行,这会大大浪费CPU的时间。

2,内核模式。

内核模式的构造是由Windows操作系统自身提供的。它们要求在应用程序的线程中调用操作系统内核的函数。将线程从用户模式切换成内核模式(或相反)会导致巨大的新能损失,这也是为什么要避免使用内核模式的原因。

优点:一个线程使用一个内核模式的构造获取一个其它线程拥有的资源时,Windows会阻塞线程,使它不浪费CPU的时间。然后当资源变得可用时,Windows会恢复线程,允许它访问资源。

3,“活锁”和“死锁”

对于在一个构造上等待的线程,如果拥有这个构造的线程一直不释放它,前者就可能一直阻塞。

“活锁”:如果这是一个用户模式的构造,线程将一直在CPU上运行,我们称之为“活锁”。

“死锁”:如果这是一个内核模式的构造,线程将一直阻塞,我们称之为“死锁”。

“死锁”总是优于“活锁”,因为“活锁”既浪费CPU时间又浪费内存,而“死锁”只浪费内存。

4,原子操作

对简单数据类型进行原子性的读和写。

比如:对于32位的cpu,4字节及以下是原子操作。64位的cpu,8字节及以下是原子操作。

如对于32位的cpu

class SomeType
{
    public class Int32 x=0;
}

我们对它进行赋值:

SomeType.x=0x01234567

x变量的值会一次性(原子性)从0x00000000变成0x01234567,这期间另一个线程不可能看到一个中间状态的值。但是如果x是一个Int64的类型。

SomeType.x=0x0123456789abcdef

另一个线程查询x的值,可能得到一个0x0123456700000000或0x0000000089abcdef。因为读写操作不是原子性的。对于32位的cpu必须分两次写入这个数据。

5,用户模式构造

这种方式是非阻止的同步,主要原理就是用了上面描述的原子操作特性。因为它不刻意阻塞线程,所以速度非常快。Thread.VolatileRead、Thread.VolatileWrite、System.Threading.Interlocked类提供的方法,以及以及C#的volatile关键字都支持原子性的操作。

重中之重:上面提到的方法,并不是真正意义上的不阻塞,而是这个阻塞发生在cpu上,是用指令来协调的,阻塞时间非常短而已。例如:如果有5个线程同时访问到Thread.VolatileRead方法,其中4个线程肯定会被阻塞。

5.1 VolatileRead和VolatileWrite

这两个方法的解释让人非常迷惑,很不容易理解。从字面意思来看,就是进行易失性读取。

对于Jeffrey总结的这条规则:当线程通过共享内存相互通信时,调用VolatileWrite来写入最后一个值,调用VolatileRead来读取第一个值

上面的这条规则是怎么的出来的?难道因为Jeffrey是牛人,我们就不动脑袋盲目接受?适度的探寻是必要的。

先来看看Thread.MemoryBarrier这个方法的作用:它强迫按照程序的顺序,之前的加载和存储操作必须在MemoryBarrier方法之前完成;之后的加载和存储操作必须在MemoryBarrier方法之后完成。这个方法是一个完整的栅栏(full fence),关于内存栅栏的概念可以google搜索。VolatileRead和VolatileWrite在内部都调用了这个类。

public static int VolatileRead(ref int address)
{
    int num = address;
    MemoryBarrier();
    return num;
}
public static void VolatileWrite(ref int address, int value)
{
    MemoryBarrier();
    address = value;
}

所以真正起作用的是Thread.MemoryBarrier方法:该方法可以阻止CPU指令的重新排列(也可阻止编译器的优化),在调用MemoryBarrier之后的内存访问不能在这之前就完成(也就是不能缓存的意思)。到现在明白了,MemoryBarrier方法后的变量访问,都会去读内存最新的值。

有了这个解释,我们在来理解VolatileRead方法就相对容易了。在调用MemoryBarrier之前,它做了一步int num = address;这会造成到内存中去取address的值赋给num,并且因为下面调用了MemoryBarrier方法,所以这一步不能被编译器优化掉,最后在MemoryBarrier方法后,返回这个最新的值。背后的实质就是利用了MemoryBarrier的特性,对要取的值做一步计算(简单赋值),然后返回,每次调用这个函数它都会重新取值。

VolatileWrite方法,它只调用了MemoryBarrier保证前面的代码都执行了并写入到了内存,最后写入新值。所以,如果你的代码和顺序无关,或代码就只有一句,你完全可以直接赋值,而不用调用这个方法。 

有点混乱,再归纳2点:

1)调用这两个方法,可以保证程序代码的顺序,因为写入(write)一个值,其他线程可能马上就会用这个值,所以要保证VolatileWrite放在函数块的最后(这样编译器就不会优化代码,移动代码的顺序)。以保证VolatileWrite前面的内容都正确的计算和存储到内存中了。其他线程根据VolatileWrite写的值,可能会用到我们刚才计算的内容,这样就不会出错。对于read一个值,把VolatileRead放在函数块的最前面(个人觉得位置不是很重要),它在这里的主要作用是保证对变量的读取是从内存中读取。

2)这两个方法中并没有保证是不是原子操作,看反编译代码你就知道。所以要自己控制使用的变量类型。这和你的CPU是32和64位密切相关。(这一点有待进一步考证

备注:从volatile关键字不支持Int64,double等64位类型,也可以间接推断出这一点。尽管这两个方法中提供了Int64,double等版本,但我觉得和cpu的位数是相关的。

案例1:对于一个可能被多线程访问的变量x,如果你在另外一个线程中轮询这个变量是否被改变,必须要用VolatileRead,以保证读到的是内存中得最新值,否则可能会出现死循环。 

        private void VolatileRW()
        {
            m_stopWork = 0;

            Thread t = new Thread(new ParameterizedThreadStart(Worker));
            t.Start(0);
            Thread.Sleep(5000);
            Thread.VolatileWrite(ref m_stopWork, 1);//设定m_stopWrok为1,这里和顺序有关,这里应该用VolatileWrite,不要妄图去猜想编译器的优化顺序
            LogHelper.WriteInfo("Main thread waiting for worker to stop");
            t.Join();

        }
        private void Worker(object o)
        {
            int x = 0;
            //while (m_stopWork == 0) //如果这样判定,m_stopWork被缓存后可能不会再去读取内存的值(循序变量可能会被编译器优化),所以可能会是个死循环
            while (Thread.VolatileRead(ref m_stopWork) == 0)//用VolatileRead每次就会去读新的值
            {
                x++;
            }
            LogHelper.WriteInfo(string.Format("worker stoped:x={0}", x));
        }

 实际测试中,用release模式编译,并且不用vs直接调试程序,上面的第一个while就会出现死循环。现在应该知道这两个函数的作用了吧。

5.2 Interlocked类

这个类的每个方法执行的是一次原子性的读取以及写入操作。这个类的所有方法都建立了完整的内存栅栏(和Thread.MemoryBarrier一样),保证了对内存的读取是最新数据。并且它能对Int64,double等执行原子操作(内部做了处理,但不是lock处理,而是用循环判断不成功就继续尝试),这一点和上面的两个方法是有区别的。

练习1(Interlocked.Add,Interlocked.Decrement,Interlocked.Read):

        private long m_threadNum = 5;//工作线程计数
        private long m_Total = 0;//总和
        private void InterlockTest()
        {
            //开启5个线程,分别计算10以内的和
            ThreadPool.QueueUserWorkItem(o => AddNumber(10));
            ThreadPool.QueueUserWorkItem(o => AddNumber(10));
            ThreadPool.QueueUserWorkItem(o => AddNumber(10));
            ThreadPool.QueueUserWorkItem(o => AddNumber(10));
            ThreadPool.QueueUserWorkItem(o => AddNumber(10));
            //上面5个都计算完了,打印出结果
            ThreadPool.QueueUserWorkItem(o => DisplaySum());
        }

        private void AddNumber(int num)
        {
            int sum = 0;
            while (num > 0)
            {
                sum += num;
                num--;
            }
            Console.WriteLine("thread {0} ok. sum={1}", Thread.CurrentThread.ManagedThreadId, sum);
            Interlocked.Add(ref m_Total, sum);//把结果加到总和上面
            Interlocked.Decrement(ref m_threadNum);//线程计数减一
        }

        private void DisplaySum()
        {
            while (Interlocked.Read(ref m_threadNum) != 0)
            {            
                //其他线程没有做完就在这里自旋,故意浪费cpu时间
            }
            //都做完了就输出结果
            Console.WriteLine("thread {0} all done,total sum = {1}", Thread.CurrentThread.ManagedThreadId, m_Total);
        }

运行结果:

thread 7 ok. sum=55
thread 11 ok. sum=55
thread 7 ok. sum=55
thread 11 ok. sum=55
thread 7 ok. sum=55
thread 11 all done,total sum = 275

这里的thread ID为7和11,感觉好像只有两个线程一样,其实不是这样的。因为这里用了Theadpool,由于我们的计算量太小(10的累加),所以CLR重复利用了空闲的线程,这也是为什么倡导多用线程池的原因。

另外:上面代码的Interlocked.Read方法只能用于Int64位的读取,所以对于Int32等应该用Thread.VolatileRead方法会比较合理。

练习2:Interlocked.Exchange方法用于单例模式出现的问题

        private void InterExchangeTest()
        {
            //开启5个线程
            new Thread(() => { Singleton.Instance().ToString(); }).Start();
            new Thread(() => { Singleton.Instance().ToString(); }).Start();
            new Thread(() => { Singleton.Instance().ToString(); }).Start();
            new Thread(() => { Singleton.Instance().ToString(); }).Start();
            new Thread(() => { Singleton.Instance().ToString(); }).Start();            
        }

        private class Singleton
        {
            private static Singleton _instance;
            private Guid _id;
            private Singleton(Guid id)
            {
                this._id = id;               
            }
            public override string ToString()
            {
                return _id.ToString();//为了便于标识对象,这里用了Guid来表示
            }

            public static Singleton Instance()
            {
                if (_instance == null)
                {
                    Singleton temp = new Singleton(Guid.NewGuid());
                    Console.WriteLine("temp:" + temp.ToString());
                    if (Interlocked.Exchange(ref _instance, temp) == null) //如果是null,代表是第一次初始化值,否者不是
                    {
                        Console.WriteLine("single thead entered.thread id ={0}", Thread.CurrentThread.ManagedThreadId);
                    }
                    else
                    {
                        Console.WriteLine("other thead entered.thread id ={0}", Thread.CurrentThread.ManagedThreadId);
                    }
                    Console.WriteLine("_instace:" + _instance.ToString());
                }
                return _instance;
            }
        }

上面的代码用Interlocked.Exchange方法来初始化单例对象,这个方法返回的是改变之前的值。如果返回的是null,则代表是第一次访问;如果不是null,则一定是多个线程进入到了代码段,第一个线程改变了_instance变量,其他线程访问时返回的就不是null了,但它们还会继续覆盖初始化好的值。测试中确实有多个线程进入,实例被多次初始化了:

temp:1d20c10a-be7b-4352-ade7-bb15e0a6ee38
single thead entered.thread id =13
_instace:1d20c10a-be7b-4352-ade7-bb15e0a6ee38//_instance第一次被初始化
temp:e333502c-757d-4526-b3c3-74f052ae0951
other thead entered.thread id =12
_instace:e333502c-757d-4526-b3c3-74f052ae0951//_instance第二次被改变

上面的问题出在Interlocked.Exchange方法会强制每个线程对_instance变量赋值,这会导致后面的线程覆盖了前面线程创建号的对象,这和单例模式相违背了。解决这个问题的关键在于,当_instance不为null时,我们希望不要强制交换值。幸好,Interlocked类提供了一个可用于比较后再赋值的原子操作方法CompareExchange,下面对上面的代码进行一个小改动:

        private class Singleton
        {
            private static Singleton _instance;
            private Guid _id;
            private Singleton(Guid id)
            {
                this._id = id;
            }
            public override string ToString()
            {
                return _id.ToString();//
            }

            public static Singleton Instance()
            {
                if (_instance == null)
                {
                    Singleton temp = new Singleton(Guid.NewGuid());
                    Console.WriteLine("temp:" + temp.ToString());
                    if (Interlocked.CompareExchange(ref _instance, temp, null) == null) //这里换用compareExchange方法
                    {
                        Console.WriteLine("single thead entered.thread id ={0}", Thread.CurrentThread.ManagedThreadId);
                    }
                    else
                    {
                        Console.WriteLine("other thead entered.thread id ={0}", Thread.CurrentThread.ManagedThreadId);
                    }
                    Console.WriteLine("_instace:" + _instance.ToString());
                }
                return _instance;
            }

CompareExchange方法,判断原始值是我们期望的值时,才进行交换。如这句Interlocked.CompareExchange(ref _instance, temp, null),我们就是希望_instance为null是才把temp赋给它。运行结果:

temp:6be7b8d8-ff1e-49b0-abf4-fafbf89c40e7
single thead entered.thread id =12
_instace:6be7b8d8-ff1e-49b0-abf4-fafbf89c40e7//第一次初始化
temp:90cc00e8-98be-4b44-be22-9957384dfd86
other thead entered.thread id =11
_instace:6be7b8d8-ff1e-49b0-abf4-fafbf89c40e7//第二次有其他线程进来,它的值还是第一次的值,并没有被覆盖

特别注意:可能你会认为下面的代码和CompareExchange是等价的,这是不对的!

                    if (_instance == null)
                    {
                       //这里仍然可能有多个线程进来
                        Interlocked.Exchange(ref _instance, temp);
                    }

CompareExchange相当于把判断放在了函数内部,但它是一个原子性的操作,其他线程进不来的。

有了以上的验证,我们实现单例模式完全可以这样写,而不用lock:

        private class Singleton
        {
            private static Singleton _instance;
            private Singleton(){}
            
            public static Singleton Instance()
            {
                if (_instance == null)
                {
                    Singleton temp = new Singleton();
                    Interlocked.CompareExchange(ref _instance, temp, null);
                }
                return _instance;
            }
        }

5.3 volatile关键字

这是为简化VolatileWrite和VolatileRead的编程而提供的关键字。对于标注为volatile的变量:就是表明该变量可能会被多线程访问,每次访问该变量都应从内存中读取新值,而不应该用寄存器中保留的值。对于多cpu的机器而言,每个cpu的寄存器可能都需要刷新,在一定程度上会损害部分性能。(上面的几个方法同样会有这个问题) 

        private volatile bool m_finish = false;
        private void VolatileKeywordTest()
        {
            m_finish = false;
            ThreadPool.QueueUserWorkItem(o => WaitingForFinish());

            Thread.Sleep(5000);
            m_finish = true;
        
        }
        private void WaitingForFinish()
        {
            while (m_finish == false)
            { }
            Console.WriteLine("the work is down");        
        }

有了这个关键字,我们就不需要再用VolatileRead方法去读取一个变量了。

 

下一篇,继续写基元线程的阻塞同步(内核模式)。

主要参考:

CLR Via C# 第三版

google