DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’

DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException异常’

背景:

在我的【DICOM系列专栏】中希望尽量涵盖关于DICOM协议的所有知识,但是在具体到某个知识点的讲解时往往会穿插使用DICOM协议的多种开源实现,例如基于C++的dcmtk、基于C#的fo-dicom、甚至是最近更加入的基于Java的dcm4che。之所以穿插介绍多种开源实现,简单概括其用意有三,第一,开源实现各有利弊,为了方便大家自己动手练习,第一时间会选择对该知识点实现最简单的开源库;第二,开源库的实现过程中应用到了许多编码和工程的设计理念,直接查看源码有时候会看起来与DICOM协议脱离甚远,因此在着重介绍DICOM协议具体内容时会使用实现直接、最符合DICOM协议原状的开源库;第三,开源库最终是为了实际应用,某些时候为了项目效率和性能考虑,会使用实现技术最新的开源库。

题记:

对于开源库的实现技术,之前部分博文也进行过简单分析。例如DICOM医学图像处理:Dcmtk与fo-dicom保存文件的不同设计模式之“同步VS异步”+“单线程VS多线程”。fo-dicom与mDCM库同属于DICOM的C#开源实现,正如上述博文所述,fo-dicom中使用了大量的.NET异步编程模式技术对mDCM进行重构。之前博文只对fo-dicom中I/O操作(文件读取和保存)的异步实现进行了介绍,近期在项目具体部署过程中遇到了一个奇葩问题,最终排查发现是由于之前对fo-dicom库中网络部分的异步实现理解有偏差所致。因此此次博文通过分析fo-dicom中的C-STORE服务实现再次分析fo-dicom中使用的.NET异步编程模型。

fo-dicom C-STORE服务弹出System.ObjectDisposedException异常

之前在DICOM医学图像处理:fo-dicom网络传输之 C-Echo and C-Store博文中对于C-STORE SCU的介绍直接使用的是fo-dicom在github上的官方示例,其代码很简单,如下所示:

var client = new DicomClient();
client.AddRequest(new DicomCStoreRequest(@"test.dcm"));
client.Send("127.0.0.1", 12345, false, "SCU", "ANY-SCP");

单个文件的发送请求与DICOM标准第7部分中对于C-STORE的具体描述严格一致,fo-dicom在具体实现时,首先利用AddRequest将数据体添加到client内部的数据链表中,然后通过Send函数利用TCP协议发送到指定的PACS服务端。
但是在C-STORE SCU服务的具体应用场景中,尤其是放射科,往往需要传送的是一个Series序列中的多张图像,也就是说需要构造多个DicomCStoreRequest对象通过AddRequest添加到DicomClient内部数据链表中,然后通过Send()进行发送。因此直接来看的话,对于序列的发送,实现代码应该是:

for(int i=0;i<series.number;++i)
{
    DicomCStoreRequest req = new DicomCStoreRequest(series.files[i]);
    client.AddRequest(req);
    client.Send("127.0.0.1",12345,false,"SCU","ANY-SCP");
}

但是在具体部署Release发布版时,会偶然随机性的出现崩溃的现象,且概率较高,传输320层图像序列大概每间隔两到三次出现一次崩溃。调试时刻发现断点停在fo-dicom库的核心代码DicomService中,如下图所示:
DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’
通过查看日志可以发现,运行过程中对于每个文件的传输会对应一次“System.ObjectDisposedException异常”。
DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’

fo-dicom C-STORE的异步实现分析

虽然调试时刻,断点停止在fo-dicom库内部DicomService类中,但是在搜索fo-dicom相关bug提交及论坛中并未发现出现过该类问题。因此决定仔细研究一下fo-dicom中C-STORE服务的实现流程,希望从中找到原因。
一次C-STORE的SCU请求在fo-dicom中实现很简洁,只需要两句代码,
1)client.AddRequest(new DicomCStoreRequest(@”test.dcm”));
**2)**client.Send(“127.0.0.1”,12345,false,”SCU”,”ANY-SCP”);
接下来让我们逐一进行分析,

DicomClient.AddRequest分析:

DicomClient中对于AddRequest的具体实现如下,

    public void AddRequest(DicomRequest request) {
        if (_service != null && _service.IsConnected) {
            //zssure:2015-04-14,try to conform whether AddRequest and Send uses the same one client
            LogManager.Default.GetLogger("Dicom.Network").Info("zssure debug at 20150414,the DicomRequest object is {0},DicomServiceUser is{1}", request.GetHashCode(), _service.GetHashCode());
            //zssure:2015-04-14,end
            _service.SendRequest(request);
            if (_service._timer != null)
                _service._timer.Change(Timeout.Infinite, Timeout.Infinite);
        } else
            _requests.Add(request);
    }

可以看出,AddRequest在向内部请求列表中添加具体DicomCStoreRequest请求时会进行判断,如果当前连接已开启(\_serivce!=null)且未断开(_service.IsConnected),AddRequest会直接触发发送操作;如果当前连接已关闭或断开,只负责将用户的DicomCStoreRequest请求添加到内部链表中,等待连接建立后触发发送操作。
按照这样的理解,只要在AddRequest添加数据之前开启连接,AddRequest单个函数就相当于发送操作。因此之前的代码可以简化,例如:

bool isConnected=false;
for(int i=0;i<series.number;++i)
{
    DicomCStoreRequest req = new DicomCStoreRequest(series.files[i]);
    client.AddRequest(req);
    if(!isConnected)
    {
        client.Send("127.0.0.1",12345,false,"SCU","ANY-SCP");
        isConnected=true;
    }
}

通过上述改进,我们期望在for循环体内之调用一次Send函数,其目的是建立连接,随后只调用AddRequest来完成后续的发送操作。但是具体测试过程中出现了,只发送成功首张图像的情况,而且多次测试都是如此,并未解决我们前文遇到的System.ObjectDisposedException异常。从代码直观来看上述代码修改后应该至少是可以完成for循环发送整个序列的,为何只发送了首张图像呢?问题可能出现在Send函数中,因此我们需要继续看下去。

DicomClient.Send分析:

DicomClient中的Send函数源代码如下:

    public void Send(string host, int port, bool useTls, string callingAe, string calledAe) {
        EndSend(BeginSend(host, port, useTls, callingAe, calledAe, null, null));
    }

这种以BeginXXX和EndXXX为命名的配套函数,就是之前博文 DICOM医学图像处理:Dcmtk与fo-dicom保存文件的不同设计模式之“同步VS异步”+“单线程VS多线程”中介绍的fo-dicom中使用的.NET中的异步编程模型,即APM。这是.NET中引入的比较早的异步编程模型,目前.NET中的代理(delegate)中都有对APM模型的实现,具体细节请阅读之前博文,这里就不介绍的。
知道了采用的是APM异步线程模型后,我们继续看,

    public IAsyncResult BeginSend(string host, int port, bool useTls, string callingAe, string calledAe, AsyncCallback callback, object state) {
        _client = new TcpClient(host, port);
        //zssure:2015-04-14,try to conform whether AddRequest and Send uses the same one client
        LogManager.Default.GetLogger("Dicom.Network").Info("zssure debug at 20150414,the TcpClient object is {0},HashCode{1}", _client.ToString(),_client.GetHashCode()); 
        //zssure:2015-04-14,end

        if (Options != null)
            _client.NoDelay = Options.TcpNoDelay;
        else
            _client.NoDelay = DicomServiceOptions.Default.TcpNoDelay;

        Stream stream = _client.GetStream();

        if (useTls) {
            var ssl = new SslStream(stream, false, ValidateServerCertificate);
            ssl.AuthenticateAsClient(host);
            stream = ssl;
        }

        return BeginSend(stream, callingAe, calledAe, callback, state);
    }

从中可以看出,DicomClient在BeginSend内部会创建新的TcpClient连接对象。待连接建立后,会再建立新的DicomServiceUser,即我们之前在AddRequest函数中看到的_service服务连接。

    public IAsyncResult BeginSend(Stream stream, string callingAe, string calledAe, AsyncCallback callback, object state) {
        var assoc = new DicomAssociation(callingAe, calledAe);
        assoc.MaxAsyncOpsInvoked = _asyncInvoked;
        assoc.MaxAsyncOpsPerformed = _asyncPerformed;
        foreach (var request in _requests)
            assoc.PresentationContexts.AddFromRequest(request);
        foreach (var context in _contexts)
            assoc.PresentationContexts.Add(context.AbstractSyntax, context.GetTransferSyntaxes().ToArray());

        _service = new DicomServiceUser(this, stream, assoc, Logger);
        //zssure:2015-04-14,try to conform whether AddRequest and Send uses the same one client
        LogManager.Default.GetLogger("Dicom.Network").Info("zssure debug at 20150414,the DicomServiceUser object is {0},HashCode{1}", _service.ToString(),_service.GetHashCode());
        //zssure:2015-04-14,end
        _assoc = new ManualResetEventSlim(false);

        _async = new EventAsyncResult(callback, state);
        return _async;
    }

BeginXXX与EndXXX函数其实是使用Windows编程中常用的事件(Event)通知的方式来实现异步处理。如果再BeginXXX函数运行后直接调用EndXXX函数会使得当前线程被阻塞。正如EndSend具体代码所示,

    public void EndSend(IAsyncResult result) {
        if (_async != null)
            _async.AsyncWaitHandle.WaitOne();

        if (_assoc != null)
            _assoc.Set();

        if (_client != null) {
            try {
                _client.Close();
            } catch {
            }
        }

        _service = null;
        _client = null;
        _async = null;
        _assoc = null;

        if (_exception != null && !_abort)
            throw _exception;
    }

在该函数中可以清晰看到EndSend函数内部在等待BeginXXX工作线程完成工作,且当前线程出于阻塞状态,即_async.AsyncWaitHandle.WatiOne();。待工作完成后,会关闭之前的TcpClient连接,抛弃内部缓存的成员变量,即_service=null,_client=null,_assoc=null。

问题解决:

至此,我们可以看出DicomClient类内部虽然内部采用了APM异步编程模式,但是以EndSend(BeginSend(XXX))方式调用的Send函数本身是同步的,在调用的地方会阻塞当前线程,直至图像发送完成,且在完成后会关闭之前建立的连接和服务。由此我们可以大致猜测出之前两次尝试的问题所在,具体情况如下表:
DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’
而之前出现的System.ObjectDisposedException异常多半是由于在for循环内部频繁重新创建/抛弃TcpClient、频繁创建/抛弃DicomServiceUser。那么如何才能解决该问题呢?,其实很简单,通过查看DicomServiceUser类的源码就知道答案了,如下所示:

        public void OnReceiveAssociationAccept(DicomAssociation association) {
            _client._assoc.Set();
            _client._assoc = null;

            foreach (var request in _client._requests)
            {
                //zssure:2015-04-14,try to conform whether AddRequest and Send uses the same one client
                LogManager.Default.GetLogger("Dicom.Network").Info("zssure debug at 20150414,the DicomRequest object is {0},the DicomClient is{1},the DicomServiceUser is {2}", request.GetHashCode(), _client.GetHashCode(),this.GetHashCode());
                //zssure:2015-04-14,end

                SendRequest(request);
            }
            _client._requests.Clear();
        }

也就是说:

在创建DicomServiceUser服务对象时,那一刻DicomServiceUser内部会对之前添加的所有DicomRequest进行遍历,逐个发送。因此要发送Series序列中的多幅图像时,在for循环内部只需要调用 AddRequest函数将每个图像添加到DicomClient内部数据列表中即可。待添加完成后,在for循环外部调用一次Send函数即可触发具体发送操作,此刻在DicomServiceUser内部会逐个将之前添加的DicomRequest进行发送。

即修改后的代码如下,

for(int i=0;i<series.number;++i)
{
    DicomCStoreRequest req = new DicomCStoreRequest(series.files[i]);
    client.AddRequest(req);
}
client.Send("127.0.0.1",12345,false,"SCU","ANY-SCP");

实际测试结果:

通过上述问题,相信已经找到了问题的解决方法。接下来让我们具体运行验证一下。下面两幅截图分别是修改前和修改后的运行结果,如下所示:
DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’

DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’
从图中我们可以看出,之前将AddRequest和Send同时放到for循环时由于频繁创建/抛弃DicomServiceUser对象,因此每一幅图像发送时都会新建连接从而输出SendAssociationRequest函数内部的调试日志。并且会抛出异常。
修改后的方案,在for循环内部运行时并未输出任何日志。待for循环退出后,执行Send函数时,只创建了一次DicomServiceUser服务对象,因此调试日志中只输出了一次SendAssociationRequest函数的调试日志,随后就是逐个发送之前添加的DicomCStoreRequest请求,只输出简单的C-STORE请求和响应日志。

ZSSURE总结:

至此我们已经解决了实际遇到的问题,如果不是因为现场部署发现,可能该问题会继续埋藏下去,为以后服务运行留下隐患。最后用一张简单的图来总结一下这次问题的原因:将AddRequest放入for循环,Send放在循环外才能高效完成C-STORE实际应用场景。
DICOM:fo-dicom之C-STORE再分析‘解决System.ObjectDisposedException错误’




作者:zssure@163.com
日期:2015-04-18