《高效编程十八式》(8/13)多线程

 

多线程

王伟冰

    什么是多线程?多线程有什么用?先来看一个例子。

    假设我们要写一个播放在线视频的程序,那么肯定会有两个步骤:下载视频,然后再播放视频。简单用代码表示就是这样:

    Download();

    Play();

    然而我们必须等到整部视频下载完了才能播放,我们希望下载和播放能够同时进行,下多少播多少。而且,由于网络的原因,下载的速度我们是无法控制的,有时我们下载完50%的时候,才播了10%,有时却已经播了40%。无论如何,只要下载完的比已经播完的要多,我们就可以继续播放而无需等待。所以,我们把下载工作和播放工作分别放在两个线程里执行(下面是C#代码,因为用C++操作线程比较烦琐):

    Thread thread1=new Thread(Download); //创建新线程

    thread1.Start(); //启动新线程,开始在新线程中执行Download()

    Play(); //在原来的线程中执行Play()

    线程的特点就是,在单个线程里,你可以肯定任何两个事件发生的时间顺序,比如先下后播时,下载完毕这个事件总比开始播放这个事件早发生;而在两个不同线程中的两个事件,一般无法预测其发生的先后顺序,比如边下边播时,我们无法判断下载完50%这个事件和播放完30%这个事件谁先谁后。

    运用多线程,可以让多个操作同时进行,节省不必要的等待时间。(快速原则8)我们可以边浏览网页,边听音乐,边下载东西,杀毒软件还会在后台帮我们扫描磁盘,因为这些操作都处在不同的线程之中。

    单核的CPU只能顺序地执行一条一条的指令,为了实现多线程,操作系统必须把CPU运行的时间划分成一段一段的时间片,比如以1ms(毫秒)为单位。在某个1ms内,CPU执行线程A的指令,在下一个1ms内,CPU执行线程B的指令,而在我们用户的眼中,就好像A和B同时在进行。

    多核的CPU就不一样,比如双核,线程A在这个核,线程B在那个核,两个核执行指令互不相干,是真正的并行运行。比如我们要复制一个大数组,在单线程的情况下可能需要一秒时间,我们可以把数组分成两部分,让两个线程同时来复制,两个线程在不同的核中执行,那么就只需要半秒的时间。所以在多核的情况下使用多线程,可以提高程序的性能。(快速原则9)

 

    当然,对于上面的播放视频的程序,还需要考虑下载速度比播放速度还慢的情况。假如我们用nDown表示已经下载的数据量,用nPlay表示已经播放的数据量,那么只有nDown比nPlay大时,才可以继续播放。Play函数可以这样写:

    void Play(){

        while(nPlay<nAll){ //nAll就是整部视频的数据量

            while(nPlay<nDown); //等待nPlay小于nDown

            PlayPart(nPlay,nDown); //播放nPlay到nDown的片段

        }

    }

    在这里,我们假设Download函数会自动修改nDown,PlayPart函数播完片段之后会自动修改nPlay。

    在不同线程中的两个事件发生的先后顺序一般是所谓的,但是有些特定事件的先后顺序还是要保证的,比如上面例子需要保证下载到nDown之后才能播放到nDown,这种保证称为“线程同步”。在上面的例子中,我们用了一种最原始的方法while(nPlay<nDown);来实现线程同步,不断地进行条件测试直到Download线程修改了nDown的值使它大于nPlay为止。这种方法称为“轮询”。但是这样做浪费很多无谓的时间在条件测试上。一种更好的方案是使用Sleep:

    while(nPlay<nDown)Thread.Sleep(100);

    只要测试条件不满足,就让当前线程睡眠100ms,把CPU让给别的线程去用。这样就不会浪费大量时间在轮询上了。

    不过这样做还有一个小问题,比如某一次测试条件不满足,于是Sleep(100)。但是在睡了50ms之后,Download线程已经下载了一大堆新的数据,可以继续播放了,但Play线程还是得再睡50ms才能醒过来继续播放,睡过头了。我们希望当Download线程积累了足够的未播放数据(比如说10000字节)的时候,能够主动通知Play线程,叫它别睡懒觉了起来播东西了。我们可以用同步事件来做到这一点:

    AutoResetEvent event1=new AutoResetEvent(false); //创建一个全局的同步事件

    void Download(){

        while(nDown<nAll){ //nAll就是整部视频的数据量

            DownloadPart(); //新下载一段数据,并修改nDown

            if(nDown>nPlay+10000)

                event1.Set(); //通知Play线程起来工作了

        }

    }

    void Play(){

        while(nPlay<nAll){

            event1.WaitOne(); //睡觉,等待Download线程来叫醒自己

            PlayPart(nPlay,nDown); //播放nPlay到nDown的片段

        }

    }

    现在Play线程就舒心了,既不用忙个不停,也不用担心睡过头。总之,避免用轮询来实现线程同步,而是用睡眠或同步事件。(快速原则10)Java中用wait和notify来实现同步事件。

 

    再来看另一个多线程的例子,假设我们要处理一些银行帐户,可以定义下面的类:

    class Account{

        double money; //当前帐户的余额

        public void add(double x){ //存入x元

            money+=x;

        }

    }

    对于一些大公司的帐户,可能会有很多客户同时往这个帐户汇款,为了避免这些人相互等待,可以为每一个汇款人分配一个线程,每个线程负责接待一位客户,并且最后调用add函数存入用户指定的数额。

    但是这样做却可能会导致错误的结果,比如一个线程存入10元,另一个线程存入20元,结果money值却只增加10元,为什么会这样?

    我们来看money+=x这一句代码,看起来是一个操作,但是实际运行时是三条指令:

    从内存读入money的值,放在寄存器中;

    从内存读入x的值,加到money所在的寄存器上。

    把寄存器中的money值写入内存。

    我们不妨将寄存器记为eax,把指令的赋值操作记为<-,那么上面指令可以简写成:

    eax<-money

    eax<-eax+x

    money<-eax

    假设money原来为0,线程1和线程2同时调用add函数,x分别为10和20,由于线程间指令执行顺序是不确定的,所以可能出现以下顺序(用123456表示):

指令

线程1

线程2

eax<-money

1

3

eax<-eax+x

2

4

money<-eax

6

5

    1、2:线程1执行前两条指令,此时eax的值为10;

    3、4、5:切换到线程2,线程2执行三条指令,此时money的值为20,注意不同线程有各自的寄存器变量,线程2修改了自己的eax但不会修改线程1的eax;

    6:切换到线程1,执行最后一条指令,线程1的eax值仍为10,所以money的值被设为10。

    由于每一个线程都共享money变量,不同线程对money变量的访问次序是无法预测的,所以出现了这样的现象。解决这个问题的方法是对共享变量加同步锁:

    class Account{

        double money;

        object moneyLock=new object(); //代表对money的锁

        public void add(double x){

            lock(moneyLock){ //锁定moneyLock对象

                money+=x;

            }

        }

    }

    被lock语句的括号括住的代码区域就像是一个带锁的房间,moneyLock就是它的锁。假设线程1先进入add函数,执行lock(moneyLock),进入房间并把房门锁住。然后线程2进入add函数,也想执行lock(moneyLock),但房门已经被锁住,它进不去,只能等待线程1执行完money+=x语句,开锁出了房间,线程2才能进去。这样就保证了,任意时候只有一个线程占有这个房间,执行里面的代码。

    原则上lock语句可以锁定任何对象,但是最好是锁定私有对象,比如上例中的moneyLock,以防止外部代码也对它进行锁定。

    所以,修改线程的共享变量时,最好对它加锁。(安全原则10)C#中的lock语句、C++中的临界区、Java中的synchronized关键字都可以用来加锁。另外,Mutex(互斥)也可以用来加锁,而且不仅可用于线程间加锁,还可以用于进程间加锁,但是系统开销比较大。

    如果所有的线程都只对共享变量进行读取,则不需要加锁。

 

    虽然加锁可以保证对共享变量的正确访问,但是不恰当的加锁可能会导致一些问题,比如线程1执行以下代码:

    lock(a){

        lock(b){

            ……

        }

    }

    线程2执行以下代码

    lock(b){

        lock(a){

            ……

        }

    }

    可能会发生什么事?假如线程1执行完lock(a),锁住了a对象;这时刚好切换到线程2,执行lock(b),锁住了b对象,想再执行lock(a),但是a已经被线程1锁住了,于是线程2等待;于是切换到线程1,想执行lock(b),但是b对象已经被线程2锁住了,所以线程1也等待。两个线程就这样永远相互等待下去,这种情况叫做“死锁”。

    一些方法可以避免死琐,比如说,有多个锁的时候,每个线程都按同样的次序加锁和解锁;不要把不会修改到共享变量的代码也写到加锁的区域中;减小加锁的“粒度”,比如说共享一个数组,如果对整个数组加锁,那么线程1访问数组时线程2就得等待,如果对数组的每一个元素加锁,那么一个线程1访问某个元素时线程2还可以访问另一个元素。

    总之,在使用同步事件或同步锁的时候,要防止死锁。(安全原则11

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值