65.9K
CodeProject 正在变化。 阅读更多。
Home

一个线程框架,用于优化 .NET 和单元线程 COM 组件之间的互操作

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (6投票s)

2007年5月2日

10分钟阅读

viewsIcon

50903

downloadIcon

803

一个通用解决方案和随附的线程框架,用于优化 .NET 和单元线程 COM 组件之间的调用

引言

随着 .NET 技术流的爆炸式增长,许多开发人员可能已经忘记了 COM。然而,由于最近的一些遭遇,它仍然在我大脑的垃圾回收第二代中!

虽然有许多关于 COM 互操作的文章,但本文重点讨论在 .NET 主机中使用单元线程组件时遇到的线程问题。我还提出了一个通用解决方案和随附的线程框架,以减少 .NET 和单元线程 COM 组件之间频繁调用时发生的线程切换。

本文的前半部分有点专注于 COM。但这并非全是遗留问题。在后半部分,我将介绍框架的一些设计元素,这些元素利用了 .NET 2.0 的一些新概念,例如部分类和嵌套类、参数化线程启动委托以及泛型,此外还有使用 COM+ 线程池的遗留线程。随附的演示(测试)项目包含使用 NUnitNUnitASP 的多态单元测试,我通过反射和堆栈跟踪来测试私有方法和状态。

COM 线程的一些背景知识

在 .NET 中使用 VB6 COM 组件时,线程是一个需要关注的领域。VB6 组件使用一种称为 STA(单线程单元)的线程模型。STA 有一个与隐藏窗口关联的消息队列,以及一个从队列处理消息的单线程。对 STA 组件的方法调用被序列化到队列中,类似于 COM+ 中排队组件的工作方式。STA 可能包含组件的多个实例,但对实例的方法调用由单元中唯一的 STA 线程按顺序处理。这种调用序列化概念避免了对实例的多个方法调用之间的竞争条件。

问题所在

如果线程模型兼容,则在客户端的单元中创建单元线程组件实例。如果不兼容,则该实例托管在新 STA 中。.NET 线程默认在 MTA(多线程单元)中运行,除非入口点用 [STAThreadAttribute] 标记。从 ASP.NET 到单元线程组件的方法调用会导致创建 STA 来托管组件实例。由于线程模型不兼容,对对象的调用涉及从客户端的 MTA 线程到组件的 STA 线程的线程切换,这会影响性能。

微软解决此问题的方法是让页面本身在 STA 中运行。这可以通过页面指令中的 ASPCompat 1 属性来实现。另一方面,Web 服务指令不支持此功能。Jeff Prosise 在 2006 年 10 月的 Wicked Code 版本中发表了一种方法 2,他创建了一个自定义 ASMX 处理程序,该处理程序派生自页面处理程序,并通过调用页面处理程序的 AspCompatBeginProcessRequest 方法将 ASMX 请求处理委托给在 STA 中运行的 Web 服务处理程序。

在 STA 中运行整个页面/Web 服务生命周期有一些隐藏的缺点。它的工作方式是,页面实例最初由从托管线程池中抽取的 MTA 线程创建。当页面上的 ASPCompat 属性设置为 true 时,请求和页面生命周期的其余部分由 ASP.NET 基础结构交给 STA 线程。这可以通过记录 Web 页面构造函数 (MTA) 和页面加载方法 (STA) 中当前线程的单元状态来验证,当 ASPCompat 属性设置为 true 时。ASP.NET I/O 和工作线程可通过 machine.config 中的进程模型进行配置,它们来自 MTA 线程池线程。此外,从压力测试中发现,在 STA 中运行页面会导致显著的性能不佳。我提出的解决方案消除了与宿主环境的任何绑定。它可以在多线程 WinForms、网页和 Web 服务应用程序中使用,而无需限制宿主环境在 STA 中运行。

Screenshot - ASPCompat1.jpg

想法

在 N 层系统中,遗留 COM 互操作的上下文可能位于层之间的任何位置,并且宿主可以是 Web 应用程序、Web 服务或其他应用程序类型。一旦线程的单元状态被设置,后续更改将不起作用。这必须在线程执行入口点之前完成。ASPCompatRunner 启动一个已初始化为 STA 的前台线程。.NET 代码(涉及与单元 COM 组件的互操作)被封装到一个方法中。其思想是在此 STA 线程中执行 .NET 方法本身,以便在该方法中实例化的任何 COM 组件都与 .NET 代码处于同一单元中。这避免了 .NET COM 可调用包装器(在 MTA 中运行)调用非托管 STA COM 组件上的方法时可能发生的 STA 和 MTA 之间的线程切换。

一个或多个涉及 COM 互操作调用的方法将注册到任务调度框架中,并且注册的集合将以批处理方式在 STA 中执行。两种类型的 .NET 方法可以注册到框架中——无返回类型方法和接受单个对象参数的方法。这些方法分别封装在 System.Threading 命名空间中定义的 ThreadStart 和新的 ParameterizedThreadStart 委托中。可以有选择地注册与方法对应的回调。框架提供了在相应调度任务完成后调用回调的机制。回调调用可以是同一 STA 线程上的同步调用,也可以是后台 MTA 线程上的异步调用,具体取决于 ExecAsyncCallback 属性的值。回调将有关状态、已调用方法和可能异常的元数据封送回调用方。框架实现的另一个功能是客户端可以等待特定注册任务或等待所有注册任务完成。

PooledASPCompatRunner

使用 ASPCompatRunner 的解决方案并不理想,因为它涉及为每个实例生成一个线程,尽管该线程可以在 STA 中执行一个或多个注册方法。生成新线程会占用服务器资源,并且由于工作线程之间过多的上下文切换也会降低性能。PooledASPCompatRunner 通过插入 COM+ 提供的 STA 线程池而不是使用自己的线程来避免这种开销。在大多数情况下,建议使用 PooledASPCompatRunner 类在 STA 中运行 .NET 代码块(任务)。

深奥的 COM+ 线程池

许多 .NET 开发人员可能都知道 COM+ 的免费功能,例如对象池、上下文、事务支持等。一个鲜为人知的功能是 COM+ 对 STA 线程池的支持。System.EnterpriseServices 命名空间包含 Activity 类和 IServiceCall 接口,它们提供了访问 COM+ 线程池的钩子。当 InvokeAsynchronously 属性为 true 时,PooledASPCompatRunner 允许客户端异步运行注册的任务,并通过回调在完成后收到通知。

设计和技术说明

ASPCompatRunner 使用泛型数据结构来存储委托、方法参数、回调和线程同步对象。我使用泛型而不是多播委托的原因之一是存在两种不同类型的操作——ThreadStart 和 ParameterizedThreadStart 委托。Delegate.Combine 方法只会合并相同类型的委托。我不会在这里详细介绍。精髓都在代码里!我建议浏览解决方案中包含的 ASPCompatNUnitTests 项目以获得更详细的理解。上面的类图以及使用代码部分应该有助于全面了解。InvokeDelegates 是一个虚方法,其在 ASPCompatRunner 中的基本实现运行在前台 STA 线程中。它在此 STA 线程上执行注册的任务。

protected virtual void InvokeDelegates()
{
    foreach (DictionaryEntry operation in _operations) 
    {
        object d = operation.Key;
        Exception ex;
        try {
            if (d is ParameterizedThreadStart)
                (d as ParameterizedThreadStart)(operation.Value);
            else (d as ThreadStart)();
            ex = null;
        }
        catch (Exception e) {
            ex = e;
        }
        finally {
            // Indexer over an operation returns the corresponding thread 

            // synchronization event

            AutoResetEvent wait = this[d];
            if (wait != null) wait.Set();
        }
        OperationCompletionDelegate cb =
            _callbacks.ContainsKey(d) ? _callbacks[d] : null;
        /* If a callback was registered invoke it
         * The default behaviour is to invoke a callback on the same STA 
         * thread
         * If ExecAsyncCallback = true, callbacks are invoked asynchronously 
         * on a threadpool MTA thread
         */
        Delegate p = d as Delegate;
        if (cb != null)
        {
            OperationCompletionMetadata data = new OperationCompletionMetadata(
                p.Target, p.Method, ex);
            if (_execAsyncCallback)
                cb.BeginInvoke(data, null, null);
            else cb.Invoke(data);
        }
    }
}

以下序列图说明了由于多态重写,PooledASPCompatRunnerASPCompatRunner 之间的区别。

Screenshot - ASPCompat2.jpg

嵌套类

在 Java 中使用嵌套类比在 .NET 中更容易。在 Java 中,可以使用“this”关键字访问内部类的实例成员以及包含的外部类。编译器隐式创建一个外部类实例,该实例绑定到内部类实例的“this”标识符。在 C# 中,外部类和内部类之间没有绑定。因此,我使用 .NET 迭代器模式将外部类引用传递给内部类,如下面的代码片段所示。

private class Task : IServiceCall
{
    private PooledASPCompatRunner _container;            

    // Can be a ThreadStart or ParameterizedThreadStart delegate

    object _operation; 
    

    private Task() { }

    public Task(PooledASPCompatRunner container, object operation) 
    {
        _container = container; _operation = operation;                
    }
    //...

}

PooledASPCompatRunner 的情况下,COM+ 活动执行任务。任务与操作委托相关联,但委托集合保存在基 ASPCompatRunner 实例中。嵌套类结构使内部类 (Task) 能够访问私有操作集合,而无需更改可访问性。

public void OnCall()
{
    Exception ex = null;
    try {
        if (_operation is ParameterizedThreadStart) {
            object arg = _container._operations[_operation];
            (_operation as ParameterizedThreadStart)(arg);
        }
        else (_operation as ThreadStart)();
    }
    catch (Exception e) {
        ex = e;
    }
    finally {
        //  Signal to the next waiting thread

        AutoResetEvent hnd = null;
        if (_container._locks.ContainsKey(_operation))
            hnd = _container._locks[_operation];
        if (hnd != null) hnd.Set();
    }
    OperationCompletionDelegate cb =
        _container._callbacks.ContainsKey(_operation)
            ? _container._callbacks[_operation] : null;
    /* If a callback was registered invoke it
     * The default behaviour is to invoke a callback on the same STA thread
     * If ExecAsyncCallback = true, callbacks are invoked asynchronously on a 
     * threadpool MTA thread
     */
    Delegate p = _operation as Delegate;
    if (cb != null) {
        OperationCompletionMetadata data = 
            new OperationCompletionMetadata(p.Target, p.Method, ex);
        if (_container._execAsyncCallback)
            cb.BeginInvoke(data, null, null);
        else cb.Invoke(data);
    }
}

使用代码

ASPCompatRunnerPooledASPCompatRunner 需要 ThreadStartParameterizedThreadStart 委托参数。使用 .NET 2.0 委托推断,可以优雅地连接委托,而无需显式地用这些委托封装方法。

简化情况(调用无返回值方法)

PooledASPCompatRunner p = new PooledASPCompatRunner();
p.Execute(obj.Method); // Method is declared as void Method();

// do some other work...

p.WaitOnAllOperations();

调用带参数的方法并在完成后进行回调

默认情况下,PooledASPCompatRunner 异步调用操作,除非 InvokeAsynchronously 属性设置为 true。OnOperationCompleted 是一个回调函数,可以直接引用,而不是像 .NET 1.x 声明那样
ASPCompatRunner.OperationCompletionDelegate callBack = new ASPCompatRunner.OperationCompletionDelegate(OnOperationCompleted);
PooledASPCompatRunner p = new PooledASPCompatRunner();
ParameterizedThreadStart operation = obj.MethodWithArg;
p.Execute(operation,
                "method parameter",
                OnOperationCompleted);
// do some other work...

// wait for that operation to complete

p.WaitOnOperation(operation);

一次调用一组方法(每次执行调用多个任务)

可以按如下方式调度一批操作。除非 ExecAsyncCallback 属性设置为 true,否则回调 (OnOperationCompleted) 在后台 STA 线程上执行。下面的代码片段使用 WaitOnAllOperations 调用来阻塞主线程,直到所有操作完成。这并非在所有情况下都必需,尤其是如果注册批次中的某些操作是“即发即弃”类型。在这种情况下,应该创建委托的显式引用并调用 WaitOnOperation(operation) 以阻塞主线程上的特定操作。
PooledASPCompatRunner p = new PooledASPCompatRunner();
p.Register(obj1.MethodWithArg, "arg1", OnOperationCompleted);
p.Register(obj2.MethodWithArg, "arg2", OnOperationCompleted);
p.Register(obj3.MethodWithArg, "arg3", OnOperationCompleted);
p.Register(obj4.Method, OnOperationCompleted);
p.Execute();
p.WaitOnAllOperations();

输出

Exec.MethodWithArg was called with parameter [arg3]
                from Thread 12 in state STA 
Exec.MethodWithArg was called with parameter [arg2]
                from Thread 14 in state STA 
Exec.Method was called with parameter []
                from Thread 15 in state STA 
Exec.MethodWithArg was called with parameter [arg1]
                from Thread 13 in state STA 
OnOperationCompleted() thread apt = STA, 
    id = 12 on NUnitTests.Exec.MethodWithArg 
OnOperationCompleted() thread apt = STA, 
    id = 14 on NUnitTests.Exec.MethodWithArg 
OnOperationCompleted() thread apt = STA, 
    id = 15 on NUnitTests.Exec.Method 
OnOperationCompleted() thread apt = STA, 
    id = 13 on NUnitTests.Exec.MethodWithArg 
Main thread apartment = MTA id = 11

结论

我很好奇,一次调用执行一批任务与每次调用执行一个任务相比如何。WebTest 项目包含一些使用 NUnitASPSystem.Net.WebClient 的表面性能测试。此外,还使用免费的 Web 应用程序压力测试工具 (WAST) 进行了 5 分钟的 Web 压力测试,以粗略地指示比较性能。免责声明是这些测试是表面的,并未涵盖广泛的负载条件。我使用了 PooledASPCompatRunner 而不是基类 ASPCompatRunner,因为前者被认为是 Web 环境典型负载的更好替代方案。

PooledASPCompatRunner 在每次执行调用中运行多个任务似乎比每次为单个任务调用执行方法能提供更好的性能。使用 PooledASPCompatRunner 的 COM 互操作比常规 COM 互操作快近 10 倍。尽管页面指令中使用 ASPCompat 属性与使用 PooledASPCompatRunner 相当,但前者的缺点是它限制了整个页面和调用堆栈中的后续方法都在 STA 中运行。在包含 COM 组件的 N 层系统中,使用 PooledASPCompatRunner 具有优势,因为它不会限制整个流程跨层在 STA 中执行。相反,层中的特定方法可以单独在 STA 中执行,而流程的其余部分可以在托管的 MTA 线程中执行,我们可以通过 machine.config 中的配置项对其进行精细控制。

RPS TTFB TTLB 时间(NUnitASP/WebClient)
常规 COM 互操作 35.58 279.98 280 31.84
使用 ASPCompat 的页面指令 490.99 19.26 19.29 6.27
PooledASPCompatRunner
每次执行调用多个任务
840.29 10.79 10.82 3.21
PooledASPCompatRunner
每次执行调用一个任务
730.17 12.58 12.61 7.97
* 时间单位:秒

参考文献

  1. MSDN - @ Page
  2. MSDN - Wicked Code
  3. Code Project - 理解 COM 单线程单元第一部分
© . All rights reserved.