设计模式与多线程——用命令模式来设计多线程架构

下载源代码

 
图一
 
图二

毫无疑问,多线程会增加写代码的难度。在有并发处理的情况下,debug变得更困难,代码中的临界区必须得到保护,共享的资源也得管理好。当然,通过增加程序运行的线程数带来的结果是:效率可以大大提高。从一个程序员的角度来说,处理线程是很令人头疼的——一些奇怪的现象在Debug时反而捕捉不到;有些Bug不能重现,只偶尔才会弹出错误提示;更糟的是,有时程序并没有运行得更快。引入并行处理的收获同这些比起来似乎有些不太值了。

通过将线程逻辑本地化并仔细的管理共享资源(这通常比表面上看起来困难得多),很多这些恼人的情形都可以避免。将命令模式与多线程应用结合起来,我们可以通过建立一个框架来完善多线程编程中的细节。这个框架将把大部分的多线程行为封装起来,并使得我们的开发减少至只要专注于应用层的逻辑。此外,这种机制使你能以开放/封闭的方式来开发,也就是说,在你将越来越多的功能增加进来的时候,没有一个工作线程的代码需要被修改到。

命令模式是一条这样的原则,它能让你“在不知道所请求的操作的任何信息或者请求接收者的任何信息时,能向对象发出请求”(请参考Gang of Four的设计模式书)。它的核心是:命令模式有一个基类(如下所示),里面有一个名为Execute()的纯虚函数,这个函数将在子类中实现,定义具体所要完成的工作。

class Command
{
public:
    virtual void Execute() = 0;
};
	  

将这个思想绑定在一个接收命令对象的多线程框架上,我们可以开发出一个多线程应用而不用纠结于复杂的多线程设计。这个框架遵循生产者/消费者模式,以工作线程为消费者。生产者产出各种的命令——都从Command这个类派生。但生产者将这些命令交给工作线程时,由于Command多态的特性,这些线程不需要知道任何的细节,只需简单的调用Execute()。由于应用的逻辑在Command的子类中封装了,这个框架能适于任何种类的工作并能在不影响已有代码的基础上扩展。

开发一个多线程程序

线程会带来什么优点呢?每当效率成为一个考量的因素,多线程就能带来性能上的收益(假使两件或两件以上的事情能同时做)。当然,除了多线程也会有其他选择,即用多个进程并发地工作。然而,线程天生就比进程有优势。起决定性的因素在于多线程编程的唯一最大的好处:线程比进程需要更少的程序以及系统的开销。并且,由于线程共享进程空间,线程间的通信和资源共享比进程间的要来得更直接。

Win32中创建线程是用CreateThread,这个函数接收一个指向线程函数(线程的入口)的指针作为参数。新建的线程将在这个函数中执行并随着函数执行的终结而终结。

以下是用CreateThread创建线程的一个例子:

#include <windows.h>
#include <iostream>
void thread_entry ()
{
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 200 );
        std::cout << "world" << std::endl;
    }
}

int main ()
{
    HANDLE thread = CreateThread ( NULL, 0,
                                  ( LPTHREAD_START_ROUTINE ) thread_entry,
                                   NULL, 0, NULL );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 100 );
        std::cout << "hello" << std::endl;
    }
    WaitForSingleObject ( thread, INFINITE );
    return  0 ;
}
	  

从这个简单的例子中你可以看到有两个线程在跑(这从交替出现的输出可以看出):一个是用CreateThread创建的,另一个是在main()中跑的。WaitForSingleObject函数使得调用者等待一个特定的线程结束,之后操作再继续进行。在上面的例子中这是必须的,因为线程函数比main中的操作花的时间要长,如果把它扔在那里不等它会使得那个线程在程序退出前没有办法做完它的工作。

为了从多线程开发得到更多的功用,你需要用mutex来保持线程间的同步。mutex是一个加锁的机制,它会保护共享资源不被同时访问。一个mutex允许一个获得锁的线程对资源有专有访问权,直到锁被释放。Mutex能用于保护文件、全局变量、计数器或任何需要被线程独占的对象的访问。Win32线程库提供了一个mutex类型和一些函数来获得和释放mutex上的锁,比如CreateMutex、WaitForSingleObject和ReleaseMutex等函数。

以下是一个用mutex变量来实现同步的例子:

#include <windows.h>
#include <iostream>
HANDLE mutex;
void thread_entry ()
{
    WaitForSingleObject ( mutex, INFINITE );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 200 );
        std::cout << "world" << std::endl;
    }
    ReleaseMutex ( mutex );
}

int main ()
{
    mutex = CreateMutex ( 0, NULL, 0 );
    HANDLE thread = CreateThread ( NULL, 0,
                                 ( LPTHREAD_START_ROUTINE ) thread_entry,
                                 NULL, 0, NULL );
    WaitForSingleObject ( mutex, INFINITE );
    for ( int i = 10; i > 0; --i )
    {
        Sleep ( 100 );
        std::cout << "hello" << std::endl;
    }
    ReleaseMutex ( mutex );
    WaitForSingleObject ( thread, INFINITE );
    return 0;
}
	  

由于使用了mutex,这一次的输出不像前一次那样是交替出现的,一个线程中的输完后才会出现另一个线程中的结果。这是由于一个线程能拿到mutex上的锁,这样另一个就被阻塞住了,直到mutex得到释放。当mutex释放后,被阻塞的线程才能获取锁,然后继续运行。

目前的例子中,CreateThread在任何需要新线程的地方被调用,但既然线程的创建的确会带来一些开销,在初始化时就创建所有的线程是很常见的,然后让它们等待适当的时机再运行。这种方式被称为“线程池”。通过用线程池,创建线程的全部开销都被安排在启动的时候,在程序运行时就不受这部分工作的影响了。

一般实现一个线程池需要用到另一种同步机制——event。Event允许线程在空闲状态下等待event发生而当选择使条件成立时也不需要产生额外开销。一个线程池用event来唤醒休眠的线程,执行任务。由于程序运行所需要的所有线程是在启动时创建,这些线程在它们的入口函数中常会有无限循环。

以下是一个从队列中读取消息的简单的例子,其中用到线程池:

#include <iostrem>
#include <string>
#include <vector>
#include <windows.h>
HANDLE condition;
HANDLE mutex;
std::vector < std::string > queue;
void thread_entry ()
{
    for ( ;; )
    {
        WaitForSingleObject ( mutex, INFINITE );
        if ( queue.empty () )
        {
            ReleaseMutex ( mutex );
            std::cout << "thread going to sleep" >> std::endl;
            WaitForSingleObject ( condition, INFINITE );

            WaitForSingleObject ( mutex, INFINITE );
            std::cout << "thread awake" << std::endl;
        }
        if ( queue.empty () )
        {
            std::cout << "work queue is empty" << std::endl;
            ReleaseMutex ( mutex );
            continue;
        }
        std::string message = queue.back ();
        queue.pop_back ();
        ReleaseMutex ( mutex );
        std::cout << message << std::endl;
        Sleep ( 1 );
    }
}

int main ()
{
    HANDLE threads[ 3 ];
    std::string messages[ 10 ] = { "hello", "goodbye", "test", "check",
                                   "one", "two", "james", "way-way",
                                   "here", "there" };
    mutex = CreateMutex ( NULL, FALSE, NULL );
    condition = CreateEvent ( NULL, FALSE, FALSE, NULL );
    for ( int i = 3; i > 0; --i )
    {
        threads[ i - 1 ] = CreateThread ( NULL, 0,
                                         ( LPTHREAD_START_ROUTINE ) thread_entry,
                                          NULL, 0, NULL );
    }
    for ( int index = 10; index > 0; --index )
    {
        WaitForSingleObject ( mutex, INFINITE );
        queue.push_back ( messages[ index - 1 ] ); 
        SetEvent ( condition );
        ReleaseMutex ( mutex );
    }
    WaitForSingleObject ( threads[ 0 ], INFINITE );
    return 0;
}
	  

可以看到,由于没有消息可以处理,线程最初都进入了睡眠状态。当main将消息推入队列后,这些线程就被event唤醒了,然后开始将队列中的消息拿出来——处理,也就是将消息简单的打印出来。线程函数中的sleep()主要是人为地要将运行时间延长,并用来显示在一个线程忙碌的状态下另一个线程是如何运作的。当一个线程完成了它的任务,它会返回到空闲的睡眠状态,再次等待有event来唤醒它工作。

命令模式

命令模式是比较简单的,只需几步来实现。首先,这个模式的基础是用一个抽象基类来实现的,这个抽象类有一个虚函数Execute()。子类都需要实现这个函数。命令模式的几个已知的用途包括支持日志功能、将请求排放在队列中以及支持撤销等,但重要的一点是:所有的命令对象有一样的接口,而且它们对请求者隐藏了接收者的信息。借由命令类我们可以很容易的达成开放/关闭原则,因为扩展功能的工作将被减少到只需要增加更多的派生的命令类而不是修改已有的类来完成。

以下是命令模式的一个简单应用:

#include <iostream>
// base command class
class Command
{
public:
    Command () {}
    ~Command () {}
    // the actual command logic resides in Execute ()
    virtual void Execute () = 0; 
};
class Command_A : public Command
{
public:
    Command_A () {}
    ~Command_A () {}
    void Execute () { std::cout << "Doing some work" << std::endl; }
};
class Command_B : public Command
{
public:
    Command_B () {}
    ~Command_B () {}
    void Execute () { std::cout << "Doing some other work" << std::endl; }
};

int main ()
{
    Command_A a;
    Command_B b;
    Command* Commands[ 2 ];
    Commands[ 0 ] = &a;
    Commands[ 1 ] = &b;
    Commands[ 0 ]->Execute ();
    Commands[ 1 ]->Execute ();
    return 0;
}
	  

命令类的多态特性使得调用者只需简单的调用Execute()而不需要知道派生的命令类的内部情形。当然,在这简单的例子中,派生的命令类究竟在做什么是一部了然的,没有任何神秘可言。但是,通过给这简单的例子加上一些线程,一个更有用的例子就会出现了(见下节)。

将命令模式和多线程结合在一起

将命令模式和一个多线程服务器结合起来会带来一些好处。最重要的是:所有核心的处理逻辑被植入到命令类中。这样,任何额外需要添加的功能都能通过增加派生的命令类来完成,而不需要触动任何已有的代码。命令类的开发有可能完全独立于多线程的逻辑开发,这使得你可以完全的测试并debug普通的逻辑而不需要立即就进入到多线程的环境中。通过将已有的代码隔离开我们将获得一个巨大的优势——你可以消除将新的bug引入到已测试并发布的代码中的风险。除此之外,因为线程逻辑可以单独并间断地开发,而不会将线程的一些调用散布在代码中,这个逻辑就可以在不需要将整个应用重新连起来的情况下被测试、debug并被完善。这将多线程编程集中到一个地方,降低了复杂性;而且只需在一个地方调整并debug代码。这对任何项目来说都是极其重要的,尤其是对那些广泛应用线程的项目。

请再看最后一个例子(类图见图一,用例见图二):

#include <iostream>
#include <vector>
#include <windows.h>
HANDLE condition;
HANDLE mutex;
class Command;
std::vector < Command* > queue;
// base command class
class Command
{
public:
    Command () {}
    ~Command () {}
    // the actual command logic resides in Execute ()
    virtual void Execute () = 0; 
};
class Command_A : public Command
{
public:
    Command_A () {}
    ~Command_A () {}
    void Execute () { std::cout << "Doing some work" << std::endl; }
};
class Command_B : public Command
{
public:
    Command_B () {}
    ~Command_B () {}
    void Execute () { std::cout << "Doing some other work" << std::endl; }
};

void thread_entry ()
{
    for ( ;; )
    {
        WaitForSingleObject ( mutex, INFINITE );
        if ( queue.empty () )
        {
            ReleaseMutex ( mutex );
            std::cout << "thread going to sleep" << std::endl;
            WaitForSingleObject ( condition, INFINITE );
            WaitForSingleObject ( mutex, INFINITE );
            std::cout << "thread awake" << std::endl;
        }
        if ( queue.empty () )
        {
            std::cout << "work queue is empty" << std::endl;
            ReleaseMutex ( mutex );
            continue;
        }
        Command* command = queue.back ();
        queue.pop_back ();
        ReleaseMutex ( mutex );
        command->Execute ();
        delete command;
        Sleep ( 1 );
    }
}

int main ()
{
    HANDLE threads[ 10 ];
    mutex = CreateMutex ( NULL, FALSE, NULL );
    condition = CreateEvent ( NULL, FALSE, FALSE, NULL );
    for ( int i = 10; i > 0; --i )
    {
        threads[ i - 1 ] = CreateThread ( NULL, 0,
                                    ( LPTHREAD_START_ROUTINE ) thread_entry,
                                     NULL, 0, NULL );
    }
    for ( int index = 10; index > 0; --index )
    {
        WaitForSingleObject ( mutex, INFINITE );
        Command* command = index % 2 ? ( Command* ) new Command_A
                                 : ( Command* ) new Command_B;
        queue.push_back ( command ); 
        SetEvent ( condition );
        ReleaseMutex ( mutex );
    }
    WaitForSingleObject ( threads[ 0 ], INFINITE );
    return 0;
}
	  

上面的例子表明:除了调用它们接收到的命令对象上的Execute(),线程自身并不包含处理逻辑。而且,所有的线程共用一个入口,如此一来,几乎所有的线程特定的信息都在那个入口函数中。命令中通常包含接收者的信息,然而,在上面的例子中,由于线程都以同样的方式处理所有的命令对象,将这个功能拿出来并在main中将命令对象显示填入队列中可能会使代码的意思更清晰。如果需要特殊的线程就不是这么一回事了,这样的话,将命令对象传给一个特定的线程或将命令对象推入一个特定的队列的逻辑会被封装在Execute()中。

为进一步拓展这里展示的命令对象的功能,可以将命令对象想象成不相关的工作包。事实上,任何类型的需要完成的工作都能被封装进命令类中。通过将完成某个特定任务所需要的信息包装在一个命令类中,框架将会处理剩下的事情。

这个处理多线程的框架可以让你将多线程开发中存在的复杂性抽离出来。根据你的程序的具体要求,你也许或也许不需要为工作线程或命令类加入更多的同步机制。通过以这个结构作为开端并使用命令模式,你可以添加并删除代码而不需要动到应用程序其他的部分。也许这不足以消除多线程编程的困难,但希望可以减少困难并帮你避免多线程编程中一些常见的陷阱。

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值