一片云雾

写博客挺浪费时间的……
  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

异步命令的同步化处理

Posted on 2011-11-02 15:15  一片云雾  阅读(2559)  评论(3编辑  收藏  举报

    本文讨论一种常见应用场合,把自己的常见处理方法进行一下总结。

    场景:假设有一个模块A,模块A下有许多子模块B1、B2、B3……Bn。这些子模块会给A的消息,可能来自不同的线程,在对这些消息进行处理时,需要访问模块A的一些数据。

    问题:如果这些异步的消息(称其为命令也可)非常多,而异步命令中访问的数据也很繁多,那么A模块的线程同步问题,将十分繁琐(繁琐的东西犯错误的可能必定大增)。

    我一向认为,程序员有时候写出犯错误的代码,绝大部分情况下不是因为他不聪明,而是因为他的方法容易犯下错误。

    对于上面这种场合,我想很多程序员首先考虑的处理方法,是对模块A的相关数据加上保护锁,然后小心谨慎的在每个命令的处理中,进行精心的控制,防止失锁或死锁。不过如果对几十个异步命令、几十个待保护的数据的处理,是不是会很容易犯下一些不经意的疏忽,然后让人调试半天?如果是几百个命令、几百个数据呢,情况又当如何?

    如果一个不小心,可能会犯下这些错误:

    某个地方忘记加锁,导致共享冲突,造成软件崩溃(比如访问了一段已经被释放的内存)。

    某个地方忘记解锁,或者多加了一次锁,造成死锁,造成对应线程死掉。

    某个命令的处理时间太长,导致触发命令的线程等待。

    某个命令的处理时间太长,导致访问同样数据的其它线程等待。

    ……

    犯下这些错误,不一定是程序员的疏忽,有很多种情况可能会导致错误的发生:

    一个粗心大意的程序员可能犯下错误。

    一个疲倦的工作时刻可能犯下错误。

    一次匆忙的修改可能犯下错误。

    一个对代码不熟悉的继任者可能犯下错误。

    一个对尘封已久的历史项目中的修改,也可能因为不再熟悉而犯下错误。

    ……

    在研究问题的解决之前,首先对异步的命令进行一下简单分类:

    1、  一个命令需要被处理并立即反馈结果;

    2、  一个命令需要立即被处理,但不需要立即反馈结果;

    3、  一个命令需要被处理,但不需要立即反馈结果;

    4、  其它类型的异步命令,其它情况本文不讨论。

    下面分析一下上面的各种情况的处理方式。

    对于第1种情况,只能对命令所访问的数据进行加锁处理。幸好这种场合非常的少,如果某个模块大量出现这种命令,我怀疑这个模块的设计本身是有疑问的。因为通过模块的输入接口来输入数据,比通过模块的输出接口来输入数据,要合理的多。

    对于第2种情况,同样只能对命令所访问的数据进行加锁。所幸运的是,这种被加锁的数据不会太多(这是指数据种类,不是数据量)。在数据量大量传输的时候,这种场合比较常见。这种情况的常见特征是,数据量大,但种类不多。比如网络下载,视音频编解码, 文件处理等情形。这种情况下,对接收数据的缓冲区进行加锁保护即可。由于被加锁的内容被访问的次数很多,但位置不多(可以这么理解,一个函数被调用次数很多,但这样的函数不多)。这样命令,其实不容易犯错。

    其实第3种情况是最常见的。比如子模块B1,内部有一个线程在干活,干完后通知上级模块A。比如子模块B2有一个线程在侦听网络命令,收到命令后通知上级模块A。如此之类的场合数不胜数。其实这种类型的命令,特点非常鲜明:

    对于触发命令的B模块而言,只需要通知A模块某件事情的发生,但并不需要该命令处理完成后再返回。或者说,对该命令的处理结果,会异步反馈给B模块。对于这种命令,还有另外一个特点,也就是命令的执行即使被延迟几十毫秒、几百毫秒,对整个系统几乎无任何影响。

    针对这种情况,我们完全可以将A模块中的所有(来自不同模块不同线程的)异步命令,同步到A模块的主线程中执行,然后再执行这些同步命令的时候,我们就不必再考虑对数据的同步保护问题。

    (上面洋洋洒洒说了好多废话,下面是正题。)

    下面介绍一种具体实现方法,完成对异步命令的同步化处理。虽然这种方法是与语言无关,且与编译环境无关的,但作为例子,总需要依托于一种具体的环境。假设模块A是一个C++应用程序的主模块,对应的类是CModuleA。

    准备工作:

    1、  首先,定义一个用于存储异步命令的结构;

    2、  然后需要一个队列,用来存储待处理的命令,同时需要一个数据保护锁;

    3、  还可以增加一个用于添加命令的辅助函数;

    4、  另外需要一个执行队列中命令的函数;

    下面是一段典型的源码:

View Code
class CModuleA
{
private:
//
//以下是异步命令同步化操作的辅助代码
//

//异步命令基类
struct CmdBase
{
CmdBase(void){}
virtual ~CmdBase(void){}
CModuleA *pParent;
virtual void Execute() = 0;
};

//待处理的命令队列(以vector作为容器来演示用法)
std::vector<CmdBase *> m_vecCmd;

//命令列表保护锁(以MFC的保护锁当示例)
CCriticalSection m_csCmd;

//添加命令到命令队列
void AddCmd(CmdBase *pCmd)
{
m_csCmd.Lock();
pCmd->pParent = this;
m_vecCmd.push_back(pCmd);
m_csCmd.Unlock();
}

//执行队列中的命令
void ProcessCmd()
{
std::vector<CmdBase *> vecCmd;
m_csCmd.Lock();
vecCmd.swap(m_vecCmd);
m_csCmd.Unlock();
for (std::vector<CmdBase *>::iterator it = vecCmd.begin(); it != vecCmd.end(); ++it)
{
(*it)->Execute();
delete *it;
}
}

};

 

5、  此外,需要在CModuleA的主线程中,定期调用ProcessCmd函数。以MFC为例,可以启动一个间隔时间很短(比如50毫秒)的定时器,也可以在OnIdle之类函数中调用。

6、 从完备性角度来考虑,最好在析构函数中加入如下代码,删除未曾处理的命令,释放未曾释放的内存:

View Code
    m_csCmd.Lock();
for (std::vector<CmdBase *>::iterator it = m_vecCmd.begin(); it != m_vecCmd.end(); ++it)
{
delete *it;
}
m_vecCmd.clear();
m_csCmd.Unlock();

 

上面6步是准备工作,步骤不算多也不算少,但代码很简单,对于模块A只需要进行一次上述操作。(实际项目中,除非是超过20万行的中大型项目,一般来说一个项目大概只有一个模块会需要上述过程。)

 

下面介绍一个实际的命令从缓存到处理的过程。假设有一个异步命令,通过回调函数调用的形式通知模块A,回调函数形如:

void DoSomethingCallback(void *pContext, int nParam, const char * lpszParam);

 

1、  首先定义用于存储该命令的结构如下:

View Code
    struct CmdDoSomething : public CmdBase
{
virtual void Execute();
int nParam;
std::string strParam;
};

 

2、  然后在触发命令的回调函数中,将该命令复制并添加到命令队列:

View Code
void CModuleA::DoSomethingCallback(void *pContext, int nParam, const char * lpszParam)
{
CModuleA *pThis = (CModuleA *)pContext;
CmdDoSomething *pCmd = new CmdDoSomething;
pCmd->nParam = nParam;
pCmd->strParam = lpszParam;
pThis->AddCmd(pCmd);
}

 

3、  然后实现该命令CmdBase的纯虚函数Excute,在该函数中执行命令时,就不必再考虑线程同步的问题,CModuleA的所有数据,都会在同一个线程中访问。

View Code
//DoSomething命令的实际处理过程
void CModuleA::CmdDoSomething::Execute()
{
//处理过程中不再需要考虑同步问题
//可以通过pParent访问CModuleA的内容
//命令的附加参数已经在结构成员中了,可直接使用
//...
}

 

以后每一次增加需要同步的异步命令时,重复上面3个步骤即可。

总结,本文介绍的内容,其实从原理到实际手法,都是很简单的。但每次需要的时候,都去重新“发明”一次,也让人觉得繁琐。因此记录在此,以后需要的时候,直接参考借鉴,省的再“发明”一次。而我所参与到的林林总总的项目,只要达到一定规模,我无一不需要用到上述方法来解决线程同步问题。实践证明,我的项目团队,也极少在开发中遇到线程死锁、共享冲突的问题,该方法是一大重要贡献。

至于这种方法各个环节,是否会产生的性能上的损失以及资源上的浪费,我进行过深入的思考和各种实际情况的模拟,答案是不会。(如果某个命令会造成性能损失和资源浪费,请思考该命令是否不适合这种手段。)

当然,在实际的应用中,可以依据上述原理对实作手段进行改进。例如我自己的实作就比这个要复杂和强大,只不过为了演示功能,我省略了原理之外的内容。