Effecitve C# 原则46:最小化与其它非托管代码的交互

JerryXia 发表于 , 阅读 (1,899)

在开发设计.Net时,MS所做的最聪明的修改之一就是他们意识到,如果没有办法整合已经存在的代码到新的.Net环境中,那没没有人会接受这个新的平台。MS知道,如果没有办法来利用已经存在的代码,这将阻止大家接受它。与其它非托管代码的交互是可以工作了,但这是可交互唯一可以拿来说一下的有利的地方。对于所有的交互策略,当操作流程在本地代码和托管代码之间的边界上进行传送时,都要求强制提供一些 编组的信号。同时,交互策略强迫开发人员必须手动的申明所有调用参数(译注:这里是说你根本不知道参数的数据类型,很多时间你都只能以int32的方式传递所有参数,例如文件句柄,字符串指针等几乎是所有的参数,都只有一个int32也就是IntPtr类型进行处理,当然这里认为是是在32位机器上。)。最后,CLR还不能完成对托管代码和本地代码的边界上进行数据传递时的优化。忽略采用本地代码或者COM对象时得到的好处吧,没有什么比这更好的了(译注:我本人强烈反对这一原则。C++,COM在目前来说,绝对有它生存的优势,我觉得应该充分利用这些优势,而不应该忽略它们)。但事实是交互并不是总能工作的,很多时候我们还是在要已经存在的应用程序中添加新的功能,提高而且更新已经存在的工具,或者在其它的地方要完成一个新的托管应用程序与旧的应用程序交互。使用一些交互在实际应用中只会是减缓对旧系统的更替。所以,有明白不同的交互策略之间有什么开销是很重要的。这些开销要同时花在开发计划以及运行时性能中。有些,最后的选择是重写旧代码。还有一些时候,你须要选择正确的交互策略。

在我讨论这个对你有用的交互策略之前,我须要花一段来讨论放弃(just throw it out)策略。第五章,与.Net框架一起工作,向你展示了一些.Net里已经为你创建好了的类和技术,你可以直接使用或者派生。为你你想的很多,你可以确定一些类和你一些代码算法,并且全部用C#重写。剩下存在的代码可以被.Net框架里已经存在的可能功能性的派生来取代。这并不会总是在任何地方,任何时候都可以工作的,但这确实是一个经过认真考虑过的迁移策略。整个第5章都推荐使用"throw it out“策略。这一原则就专注于交互,而它确实是件痛苦的事情。

接下来,让我们假设你已经决定重写全部代码并不实际。一些不同的策略要求你从.Net中访问本地代码。你须要明白在本地代码和托管代码的边界上传递数据时的开销的低效。在使用交互时有三个开销。首先就是数据集群处理,这在托管堆和本地代码堆之间进行数据传递时发生。其次就是在托管代码和非托管代码进行交互时的大量数据吞吐时的开销。你以及你的用户要承担这些开销。第三个开销就只是你自己的了:你要在这个混合的开发环境中添加很多工作来实现交互。这也是最糟糕的一个,所以你的设计应该决定最小化这样的开销。

让我们开始讨论交互时在性能上的开销,以及如何最小化这些开销。数据集群是最大的一个因数,就像是网络服务或者远程操作一样,你须要尽可能使用笨重的(chunky)API而不是小巧的(chatty )API(译注:数据集群是指你没有办法即时的与本地代码进行交互,而有一个延时,这个延时就使用数据堆集起来一起处理,这样就使得你应该尽可能少的与本地代码进行交互,而要选择一些一次可以处理较多数据的API)。你可以用不同的方法来完成与非托管代码的交互。你可以重新修改已经存在的非托管代码来创建一个笨重的API,更适合交互的API。常规的COM应用中是申明很多属性,这样客户可以设置并修改COM对象内部的状态或者行为。每次的设置属性都会集群数据,而且不得不穿越边界。(而且每在穿越交互边界时也会有thunks。)这非常的低效,不幸的是,COM对象或者非托管库可能不受你控制。当这种情况发生时,你须要完成更麻烦的工作。这时,你可以创建一个轻量级的C++库,通过使用你所须要的chunkier API来暴露类型的功能。这就要增加你的开发时间了(也就是第三个开销)。

当你封装一个COM对象时,确保你修改的数据类型已经在本地代码一托管代码之间提供了最好的数据集群策略。有些类型可以很好的比其它类型进行集群,试着限制用于在本地代码和托管代码之间进行传递的数据类型,尽量使用blittable数据。blittable是指托管代码和本地代码都一样使用的类型。数据内容可以直接拷贝而不用管对象的内部的结构。某些情况下,非托管代码可能使用托管代码的代码。下面列出了blittable 类型:

System.Byte
System.SByte
System.Int16
System.UInt16
System.Int32
System.UInt32
System.Int64
System.UInt64
System.UIntPtr

另外,任何的blittable类型的一维数组也是blittable类型。最后,任何格式化的包含blittable类型的也是blittable类型。一个格式化的类型可以是一个用StructLayoutAttribute明确定义了数据层次的结构,

[ StructLayout( LayoutKind.Sequential ) ]
public struct Point3D
{
  public int X;
  public int Y;
  public int Z;
}

当你在托管代码和非托管代码之间,只使用blittable 类型时,你就最小化了多少必须拷贝的信息呀!你同样也优化了任何必须发生的拷贝操作。

如果在数据拷贝时,你不能限制数据类型让它成为blittable 类型,你可以使用InAttribute 和OutAttribute 来进行控制。也COM类似,这些特性控制数据拷贝的方法。In/Out 参数双向拷贝,In参数以及Out参数是一次拷贝。确保你应用严格限制的In/Out组合,来避免更多不必须拷贝。

最后,你可以通过申明如何集群数据来提高性能。对于字符串来说这是最常见的。集群字符串默认是使用BSTRs。这是一个安全的策略,但这也是最低效的。你可以通过修改默认的集群格式减少额外的拷贝操作,可以使用MarshalAs 特性来修改集群方式。下面申明了字符串的集群使用LPWStr或者wchar*:

public void SetMsg(
  [ MarshalAs( UnmanagedType.LPWStr ) ] string msg );

这有一个关于处理托管和非托管层上数据的轶事:数据被拷贝然后在托管和非托管类型之间进行传输。你有三个方法业最小化拷贝操作。首先就是通过限制参数和返回值,让它们是blittable类型。这应该是首选的。当你不能这样做时,应用In和Out特性来最小化必须完成的拷贝和传输操作。最后一个优化就是,很多类型可以不只一种集群方式,你应该选择最优化的一种。

现在让我们转到如何在托管的非托管组件中转移程序控制。你有三种选择:COM交互,平台调用(P/Invoke),以及托管C++。每种方法有它自己的优势和劣势。

COM交互是最简单的方法来使用已经存在的COM组件。但COM交互也是在.Net中和本地代码交互时最低效的一种方式。除非你的COM组件已经有很重要的利益,否则不要这样做。不要看也不要想这种方式。如果你没有COM组件而要使用这种方法就意味着你要把COM和交互原则学的一样好。没时间让你开始理解IUnknown(译注:COM原理中最基本的接口,任何COM都实现了这样的接口)。那些这样做的人都试着从我们的内存中尽快的清理它们。使用COM交互同样意味着在运行时你要为COM子系统承担开销。你同样还要考虑,在不同的CLR对象的生命期管理和COM版本的对象生命期管理之间又意味看什么。你可以服从CLR的原则,这就是说做一个你所导入的COM对象有一个析构函数,也就是在COM接口上调用的Release()。或者你可以自己清楚的使用ReleaseCOMObject()来释放COM对象。第一种方法会给应用程序引入运行时的低效(参见原则15)。而第二个在你的程序里又是头疼的事。使用ReleaseCOMObject ()就意味看你要深入到管理细节上,而这些CLR的COM交互已经帮我们完成了。你已经了解了,而且你认你明白最好的。CLR请求所有不同,而且它要释放COM对象,除非你正确的告诉它,你已经完成了。这是一个极大的鬼计,因为COM专家程序员都是在每个接口上调用Release(),而你的托管代码是以对象处理的。简单的说,你必须知道什么接口已经在对象上添加了AddRef,而且只释放这些(译注:COM的引用非常严格,每个引用都会添加一个AddRef,释放时必须明确的给出Release(),而且必须成对使用,而在.Net对COM进行封装后,很多时候就是这个引用不匹配而出现资源泄漏)。就让CLR来管理COM的生命期,你来承担性能损失就行了。你是一个烦忙的开发人员,想在.Net中混合COM资源到托管环境里,你要学习的太多了(也就是第三个开销)。
第二个选择是使用P/Invoke。这是一个更高效的方法来调用Win32的API,因为你避免了在上层与COM打交道。而坏消息是,你须要为你使用的每个P/Invoke方法手写这些接口。越是调用多的方法,越是多的申明必须手写。这种P/Invoke申明就是告诉CRL如何访问本地方法。这里额外的解释一下为什么每个一个关于P/Invoke的例子里(也包括下面这个)都使用MessageBox:

public class PInvokeMsgBox
{
   [ DllImport( "user32.dll" ) ]
   public static extern int MessageBoxA(
      int h, string m, string c, int type );

   public static int Main()
   {
      return MessageBoxA( 0,
        "P/InvokeTest",
        "It is using Interop", 0 );
   }
}

另一个使用P/Invoke的主要缺点是,这并不是设计成面向对象的语言。如果你须要导入C++库,你必须在你的导入申明中指明封装名。假设取代Win32的MessageBox API,你想访问MFC的C++DLL里的另外两个AfxMessageBox 方法。你须要为其中一个方法创建一个P/Invoke申明:

?AfxMessageBox@@YGHIII@Z
?AfxMessageBox@@YGHPBDII@Z

这两个申明名是与下面的两个方法匹配的:

int AfxMessageBox( LPCTSTR lpszText,
  UINT nType, UINT nIDHelp );
int AFXAPI AfxMessageBox( UINT nIDPrompt,
  UINT nType, UINT nIDHelp);

即使是在重写少数几个方法之后,你很快就明白这不是一个高产的方法来提供交互功能。简单的说,使用P/Invoke只能访问C风格的Win32方法(在开发上要开销更多的时间)。

最后一种选择就是在Microsoft C++编译器上使用/CLR开关来混合托管的非托管代码。如果你编译你所有的本地代码使用/CLR,你就创建了一个基于MSIL的库,该库使用本地堆来存储所有的数据。这就是说,这样的C++库不能直接被C#调用。你必须在你所熟悉的代码上创建一个托管的C++库,用于在托管和非托管类型之间创建一个桥梁,提供在托管和非托管的堆之间的数据集群支持。这样的C++库包含托管类,这些数据成员是基于托管堆的。这些类同样包含对本地对象的引用:

// Declare the managed class:
public __gc class ManagedWrapper : public IDisposable
{
private:
  NativeType* _pMyClass;

public:
  ManagedWrapper( ) :
    _pMyClass( new NativeType( ) )
  {
  }
  // Dispose:
  virtual void Dispose( )
  {
    delete _pMyClass;
    _pMyClass = NULL;
    GC::SuppressFinalize( this );
  }

  ~ManagedWrapper( )
  {
    delete _pMyClass;
  }

  // example property:
  __property System::String* get_Name( )
  {
    return _pMyClass->Name( );
  }
  __property void set_Name( System::String* value )
  {
    char* tmp  = new char [ value->Length + 1 ];
    for (int i = 0 ; i < value->Length; i++ )
      tmp[ i ] = ( char )value->Chars[ i ];
    tmp[ i ] = 0;
    _pMyClass->Name( tmp );
    delete [] tmp;
  }

  // example method:
  void DoStuff( )
  {
    _pMyClass->DoStuff( );
  }

  // other methods elided...
}

再说一次,这并不是一个像以前使用过的高产的程序开发工具。这只是代码重复,而且整个目的都要在托管和非托管数据之间进行集群数据和thunking。优势是,你可以从你的本地代码中暴露出来的方法和属性上完成控制。而劣势是你必须手写一部份.Net代码以及一部份C++代码。在这两种代码之间转换很容易让你发生错误。你还不能忘记删除非托管对象。托管对象不是你 要负责的。这会让你的开发进度降下来,因为要不断的检测这是否正确。

使用 /CLR 开关听上去像是一个神话,但对于所有的交互来说,这并不是一个神弹(magic bullet)。C++里的模板和异常处理与C#是大不相同的。写的很好很高效的C++并不一写能转化成最好的MSIL结构。更重要是,编译C++代码时的 /CLR开关并不做确认。正如我前面所说的,这样的代码是使用本地堆:它访问本地内存。而CLR并不能验证这些代码是安全的。调用这些代码的程序必须确保有安全许可来访问不安全代码。虽然如此,/CLR策略还是最好的一个方法,在.Net中利用已经存在的C++代码(不是COM对象)。你的程序不会招致thunking开销,而为你的C++库现在并不在MSIL中,MSIL不是本地的CPU指令。

交互操作是件头疼的事。在你使用交互之间,认真的考虑写本地应用程序。这经常是简单而且很快的。不幸的是,对于很多的开发人员来说,交互是必须的。如果你有已经存在的用其它语言写的COM对象,使用COM交互。如果你有已经存在的C++代码,使用 /CLR 开关并托管C++来提供最好的策略来方法已经存在的本地代码。选择最省时间的一个策略,这可能就是“just thro it out”策略。

添加新评论