实例解析C++/CLI线程之多任务

实例解析C++/CLI 线程之多任务
 
 
         简介
         从处理器的角度来看,线程是一个单独的执行流程,每个线程都有各自的寄存器及堆栈上下文。通常来说,在系统中只有一个处理器或处理器只有一个核心时,运行时环境在一个时间片内只能执行一个线程,当线程未能获取所需的资源时,线程的执行就会被中断,且会一直等到相关操作的完成,如I/O;或者在线程用完它的处理器时间片时,也会被中断下来等待。而处理器把执行流程从一个线程切换到另一个线程时,这称为“上下文切换”;当某个线程变为“阻塞”状态,从而执行另一个线程时,系统有效地减少了处理器空闲时间,这称为“多任务”。
         当程序执行时,系统知道可以从磁盘上某处获取相关的指令及静态数据,程序会被分配到一组包含虚拟内存在内的地址空间,这个运行时上下文被称为“进程”。然而,在一个进程可以运行之前,它必须拥有至少一个线程,也就是说,当一个进程被创建时,它自动被赋予了一个线程,这称为“主线程”。但是话说回来,这个线程与之后这个进程所创建的线程相比,没有任何不同之处,它只不过恰好是这个进程的第一个线程而已。一般来说,在程序的控制之下,进程内的线程数在运行时会有所变化,任何线程都可以创建其他的线程,但不管怎样,线程不拥有它所创建的线程,所有进程内的线程都是作为一个整体属于这个进程。
 
         可把进程要完成的工作分成不同的“子任务”,每一部分都由不同的线程来执行,这称为“多线程”。进程内的每个线程共享同样的地址空间与进程资源,当最后一个进程内的线程结束时,父进程就结束了。
         为何进程内要有多个线程呢?如果进程只有一个线程,那么它的执行流程是自上而下顺序执行的;当线程阻塞,而又没有其他的活动线程处于等待状态时,系统就会进入空闲状态;如果此时进程的子任务必须被顺序地执行,那么这种情况就不可避免,将花费大量的时间来等待。然而,绝大多数的进程都不是这样的,试想有这样一种情况,某个进程有多个选项,用户可以选择其中一些选项,由此产生的计算会使用内存或文件中的数据,并生成结果,如果能从中分出一些新的线程,那么进程不必等待前一个计算的结果,就可以继续接受新的计算请求。此外,通过指定线程的优先级,进程可只在更关键的线程阻塞时,才运行次关键的线程。
 
         在有多个线程的情况下,某些线程可负责程序的主要工作,而另一个线程可用于处理键盘和鼠标的输入。例如,用户可能会觉得前一次请求并不是期望的动作,从而希望取消由前一次请求产生的那一个线程,这时就可在某个下拉菜单中进行选择,由一个线程去终止另一个线程。
         另一个例子就是打印假脱机程序,它的任务是保持打印机尽可能地满载工作,并处理用户的打印请求;如果这个程序必须要等到前一项打印工作完成,才能接受新请求的话,用户可能会感到非常的不满。当然,程序也可周期性地停下打印工作,来查看是否有新的未处理请求(这称为“轮询”),但是,如果没有新请求,这将会非常浪费时间。另外,如果轮询的间隔时间太长,对处理新请求,还会造成延时;如果间隔太短,那么线程在轮询上花费的时间又太多。那么,为什么不让假脱机程序有两个线程呢?一个用于将打印工作传递到打印机,而另一个用于处于用户的请求,它们之间都相互独立运行;而当一个线程工作完成时,它要么结束自身,要么进入休眠状态。
 
         当处理并发的执行线程时,必须要首先了解两个重要的概念:原子性和重入性。一个原子变量或对象是作为一个整体被访问的,甚至于在异步操作的情况下也是如此——访问的是同一个变量或对象。举例来讲,如果一个线程正在更新一个原子变量或对象,而另一个线程在读取其内容,此时来讲,内容逻辑上的完整性是不可能被破坏的,所以,要么读取到旧值,要么读取到新值,而不会旧值新值各读一部分。通常来说,能被原子性访问的变量或对象,只是那些在硬件上能被原子性支持的类型,如字节(Byte)和字(Word)。C++/CLI中大多数的基本类型都确保具有原子性,剩下的类型也可被某种特定的实现支持原子性,但不能百分百保证。显而易见,一个实现了x与y坐标对的Point对象,不具有原子性,对Point值的写入,可能会被对其值的读取中断,结果就是,读取到了一个新的x值和一个旧的y值,反之亦然;同样地,数组也不可能被原子性地访问。正是因为大多数的对象不能被原子性地访问,所以必须使用一些同步形式来保证在某一时间,只有一个线程可操纵某个特定的对象。也正是因为此,C++/CLI分配给每一个对象、数据和类一个同步锁。
         一个重入的函数可由多个线程安全地并行执行。当线程开始执行一个函数时,在函数中分配的所有数据都来自栈或堆,但无论如何,对此调用来说,都是唯一的。如果在另一个线程仍处于工作状态时,本线程开始执行同一个函数,那么,每个线程中的数据都是相互独立的。然而,如果函数访问线程间共享的变量或文件时,则必须使用某些同步方法。
 
 
         创建线程
         在例1中,主线程创建了两个其他的线程,这三个线程并行运行,并且未进行同步。在线程间并未共享数据,且当最后一个线程结束时,主进程也结束了。
 
例1:
using namespace System;
using namespace System::Threading;
 
public ref class ThreadX
{
 int loopStart;
 int loopEnd;
 int dispFrequency;
public:
 ThreadX(int startValue, int endValue, int frequency)
 {
    loopStart = startValue;
    loopEnd = endValue;
    dispFrequency = frequency;
 }
 
/*1*/ void ThreadEntryPoint()
 {
/*2*/    String^ threadName = Thread::CurrentThread->Name;
   
    for (int i = loopStart; i <= loopEnd; ++i)
    {
      if (i % dispFrequency == 0)
      {
        Console::WriteLine("{0}: i = {1,10}", threadName, i);
      }
    }
    Console::WriteLine("{0} thread terminating", threadName);
 }
};
 
int main()
{
/*3a*/ ThreadX^ o1 = gcnew ThreadX(0, 1000000, 200000);
/*3b*/ Thread^ t1 = gcnew Thread(gcnew ThreadStart(o1, &ThreadX::ThreadEntryPoint));
/*3c*/ t1->Name = "t1";
 
/*4a*/ ThreadX^ o2 = gcnew ThreadX(-1000000, 0, 200000);
/*4b*/ Thread^ t2 = gcnew Thread(gcnew ThreadStart(o2, &ThreadX::ThreadEntryPoint));
/*4c*/ t2->Name = "t2";
 
/*5*/ t1->Start();
/*6*/ t2->Start();
 Console::WriteLine("Primary thread terminating");
}
 
         请看标记3a中第一条可执行语句,此处我们创建了一个用户自定义ThreadX类型的对象,这个类有一个构造函数、一个实例函数及三个字段。我们调用构造函数时,传递进一个开始、一个结束计数,及一个固定增量,其用于循环控制。
         在标记3b中,创建了一个库类型System::Thread的对象,它源自命名空间System::Threading,可用此对象来创建一个新的线程,但是,在线程可以工作之前,它必须要知道从哪开始执行,所以传递给Thread构造函数一个System::ThreadStart代理类型,其可支持不接受参数的任意函数,且没有返回值(作为一个代理,它可封装进多个函数,在本例中,只指定了一个)。在上面的代码中,指定了线程由执行对象o1的ThreadEntryPoint实例函数开始,一旦开始之后,这个线程将会执行下去直到函数结束。最后,在标记3c中,随意使用了一个名称,以设置它的Name属性。
         请看标记4a、4b及4c,第二个线程也一样,只不过设置了不同的循环控制及名称。
         眼下,已构造了两个线程对象,但并未创建新的线程,也就是说,这些线程处于未激活状态。为激活一个线程,必须调用Thread中的Start函数,见标记5与6。通过调用进入点函数,这个函数启动了一个新的执行线程(对一个已经激活的函数调用Start将导致一个ThreadStateException类型异常)。两个新的线程都各自显示出它们的名称,并在循环中定时地显示它们的进度,因为每个线程都执行其自身的实例函数,所以每个线程都有其自己的实例数据成员集。
         所有三个线程均写至标准输出,见插1,可看出线程中的输出是缠绕在一起的(当然,在后续的执行中,输出也可能有不同的顺序)。可见,主线程在其他两个线程启动之前就结束了,这证明了尽管主线程是其他线程的父类,但线程的生命期是无关的。虽然,例中使用的进入点函数无关紧要,但其可调用它可访问的任意其他函数。
 
插1:三个线程的缠绕输出
 
Primary thread terminating
t1: i =           0
t1: i =      200000
t1: i =      400000
t1: i =      600000
t2: i =    -1000000
t2: i =     -800000
t2: i =     -600000
t2: i =     -400000
t2: i =     -200000
t2: i =           0
t2 thread terminating
t1: i =      800000
t1: i =     1000000
t1 thread terminating
 
         如果想让不同的线程由不同的进入点函数开始,只需简单地在同一或不同的类中,定义这些函数就行了(或作为非成员函数)。
 
 
         同步语句
         例2中的主程序有两个线程访问同一Point,其中一个不断地把Point的x与y坐标设置为一些新值,而另一个取回并显示这些值。即使两个线程由同一进入点函数开始执行,通过传递一个值给它们的构造函数,可使每个线程的行为都有所不同。
 
例2:
using namespace System;
using namespace System::Threading;
 
public ref class Point
{
 int x;
 int y;
public:
 
//定义读写访问器
 
 property int X
 {
    int get() { return x; }
    void set(int val) { x = val; }
 }
 
 property int Y
 {
    int get() { return y; }
    void set(int val) { y = val; }
 }
 
 // ...
 
 void Move(int xor, int yor)
 {
/*1a*/     Monitor::Enter(this);
    X = xor;
    Y = yor;
/*1b*/     Monitor::Exit(this);
 }
 
 virtual bool Equals(Object^ obj) override
 {
 
    // ...
 
    if (GetType() == obj->GetType())
    {
      int xCopy1, xCopy2, yCopy1, yCopy2;
      Point^ p = static_cast<Point^>(obj);
 
/*2a*/       Monitor::Enter(this);
      xCopy1 = X;
      xCopy2 = p->X;
      yCopy1 = Y;
      yCopy2 = p->Y;
/*2b*/       Monitor::Exit(this);
 
      return (xCopy1 == xCopy2) && (yCopy1 == yCopy2);
    }
 
    return false;
 }
 
 virtual int GetHashCode() override
 {
    int xCopy;
    int yCopy;
 
/*3a*/     Monitor::Enter(this);
    xCopy = X;
    yCopy = Y;
/*3b*/     Monitor::Exit(this);
    return xCopy ^ (yCopy << 1);
 }
 
 virtual String^ ToString() override
 {
    int xCopy;
    int yCopy;
 
/*4a*/     Monitor::Enter(this);
    xCopy = X;
    yCopy = Y;
/*4b*/     Monitor::Exit(this);
 
    return String::Concat("(", xCopy, ",", yCopy, ")");
 }
};
 
public ref class ThreadY
{
 Point^ pnt;
 bool mover;
public:
 ThreadY(bool isMover, Point^ p)
 {
    mover = isMover;
    pnt = p;
 }
 
 void StartUp()
 {
    if (mover)
    {
      for (int i = 1; i <= 10000000; ++i)
      {
/*1*/        pnt->Move(i, i);
      }
    }
    else
    {
      for (int i = 1; i <= 10; ++i)
      {
/*2*/        Console::WriteLine(pnt); // calls ToString
        Thread::Sleep(10);
      }
    }
 }
};
 
int main()
{
 Point^ p = gcnew Point;
 
/*1*/ ThreadY^ o1 = gcnew ThreadY(true, p);
/*2*/ Thread^ t1 = gcnew Thread(gcnew ThreadStart(o1, &ThreadY::StartUp));
 
/*3*/ ThreadY^ o2 = gcnew ThreadY(false, p);
/*4*/ Thread^ t2 = gcnew Thread(gcnew ThreadStart(o2, &ThreadY::StartUp));
 
 t1->Start();
 t2->Start();
 
 Thread::Sleep(100);
/*5*/ Console::WriteLine("x: {0}", p->X);
/*6*/ Console::WriteLine("y: {0}", p->Y);
 
/*7*/ t1->Join();
 t2->Join();
}
 
         调用Sleep休眠100毫秒的目的是为了在可以访问x与y坐标之前,让两个线程开始执行,这就是说,我们想要主线程与其他两个线程竞争坐标值的独占访问。
         对Thread::Join的调用将会挂起调用线程,直到被调用线程的结束。
         请看例2中的ThreadY类,当一个线程调用标记1中的Move,而另一个线程隐式地调用标记2中的ToString时,潜在的冲突就发生了。因为两个函数没有用同步措施来访问同一个Point,Move可能会先更新x坐标,但在它更新相应的y坐标之前,ToString却显示了一对错误的坐标值,这时,输出可能会如插2a所示。然而,当相关的语句被同步之后,ToString显示的坐标对总是正确匹配的,同步执行之后的输出如插2b所示。再看一下例2中的Point类型,在此可看到这些访问x与y坐标的函数是如何被同步的。
 
插2:a线程输出产生了不匹配的坐标对;b同步执行中匹配的坐标对
 
(a)
(1878406,1878406)
(2110533,2110533)
(2439367,2439367)
(2790112,2790112)
x: 3137912
y: 3137911         // y与x不同
(3137912,3137911) // y与x不同
(3466456,3466456)
(3798720,3798720)
(5571903,5571902) // y与x不同
(5785646,5785646)
(5785646,5785646)
 
(b)
(333731,333731)
(397574,397574)
(509857,509857)
(967553,967553)
x: 853896
y: 967553 // y仍与x不同
(1619521,1619521)
(1720752,1720752)
(1833313,1833313)
(2973291,2973291)
(3083198,3083198)
(3640996,3640996)
 
         在此,可把一段语句放在一个称作“同步锁”——即Thread::Monitor的Enter与Exit语句当中,来进行对某些资源的独占式访问,如标记1a与1b、2a与2b、3a与3b、4a与4b。
         因为Move与ToString都是实例函数,当它们在同一Point上被调用时,它们共享Point的同步锁,为独占访问一个对象,就必须传递一个指向对象的句柄给Enter。如果在ToString访问时,Move也被调用操作同一Point,Move将会一直处于阻塞状态,直至ToString完成,反之亦然。结果就是,函数花费时间在相互等待,反之没有同步,它们都会尽可能快地同时运行。
         一旦同步锁控制了对象,它将保证在同一时刻,只有一个此类的实例函数可以在对象上执行它的关键代码。当然,类中没有使用同步锁的其他实例函数,可不会理会它的同步“兄弟”在做些什么,所以,必须小心适当地使用同步锁(注意,X与Y的访问器未被同步)。同步锁对于那些操作不同对象的实例函数,将不起任何作用,这些函数不会互相等待。
         通常地,当调用Exit时,同步锁就被释放了,因此,同步锁的作用范围就是Enter与Exit中间的那些代码,程序员必须有责任避免死锁问题的发生——防止线程A一直等待线程B,或反之。
         假设有一个包含25条语句的函数,其中只有3条连贯的语句需要同步,如果我们把全部的25条语句都包括在一个同步锁中,那么,将把资源比实际所需锁住了更长的时间。正如前述代码所示,每个同步锁保持的时间都要尽可能地短。
 
         请看例3中的ArrayManip结构,当同步锁执行到标记2时,锁中的array正处于忙碌状态,因此将会阻塞其他所有在array上需要同步的代码。
 
例3:
using namespace System;
using namespace System::Threading;
 
public ref struct ArrayManip
{
 static int TotalValues(array<int>^ array)
 {
/*1*/    int sum = 0;
/*2*/    Monitor::Enter(array);
    {
      for (int i = 0; i < array->Length; ++i)
      {
        sum += array[i];
      }
    }
    Monitor::Exit(array);
    return sum;
 }
 
 static void SetAllValues(array<int>^ array, int newValue)
 {
/*3*/    Monitor::Enter(array);
    {
      for (int i = 0; i < array->Length; ++i)
      {
        array[i] = newValue;
      }
    }
    Monitor::Exit(array);
 }
 
 static void CopyArrays(array<int>^ array1, array<int>^ array2)
 {
/*4*/    Monitor::Enter(array1);
    {
/*5*/      Monitor::Enter(array2);
      {
        Array::Copy(array1, array2,
          array1->Length < array2->Length ? array1->Length
          : array2->Length);
      }
      Monitor::Exit(array2);
    }
    Monitor::Exit(array1);
 }
};
 
         一个同步锁可包含同一对象的另一个同步锁,在这种情况下,锁计数相应地增长了;但如果想被另一个线程中的同步语句操作,必须先递减到零。一个同步锁还可包含不同对象的同步锁,在此情况下,它将会一直阻塞,直到第二个对象可访问,函数CopyArrays就是一个例子。
         一般来说,使用同步锁的目的,是为了使用父类函数的实例对象,然而,我们在不需要这些对象实际包含任何信息的情况下,也能“创造”出锁对象和同步机制。请看例4,类C有一个名为Lock的同步锁,其并未包含任何数据,且除了一个同步锁外,从未进行初始化或使用在任何上下文中。但在函数F3与F4中,则分别包含了一些语句,各自在运行时必须阻塞对方的运行。
 
例4:
using namespace System::Threading;
 
public ref class C
{
/*1*/ static Object^ Lock = gcnew Object;
 
public:
 static void F1()
 {
/*2*/    Monitor::Enter(C::typeid);
/*3*/    try {
      //执行一些操作
    }
    finally {
      Monitor::Exit(C::typeid);
    }
 }
 
 static void F2()
 {
    Monitor::Enter(C::typeid);
    // ...
    Monitor::Exit(C::typeid);
 }
 
 static void F3()
 {
/*4*/    Monitor::Enter(Lock);
    // ...
    Monitor::Exit(Lock);
 }
 
 static void F4()
 {
    Monitor::Enter(Lock);
    // ...
    Monitor::Exit(Lock);
 }
};
 
         如果一个类函数(而不是一个实例函数)需要同步,可使用typeid操作符来包含一个锁对象,如标记2中所示。对每个CLI类型而言,都有一个锁对象,同样,对类型的每个实例而言,也有一个锁对象。类上的同步锁意味着在同一时刻,只能执行一个类函数。
         注意标记3中的try/finally,一般而言,如果同步锁中的执行正常完成,将如前面的例子一样,正常地调用Monitor::Exit;但是,如果在同步锁中抛出了一个异常,将不会调用到Exit,因为正常的执行流程已经被中断了。那么我们要做的就是,如果同步锁中可能存在一丝机会发生异常——不管是同步锁中直接或是间接调用的任何函数,我们都必须加上try/finally语句块,这样的话,不管是同步锁的正常或非正常退出,都会调用到Exit了。
 
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值