Excel 中的多线程处理和内存争用

适用于:Excel 2013 | Office 2013 | Visual Studio

早于 Excel 2007 的 Microsoft Excel 版本使用单个线程进行所有工作表计算。 但是,从 Excel 2007 开始,可以将 Excel 配置为使用 1 到 1024 个并发线程进行工作表计算。 在多处理器或多核计算机上,默认线程数等于处理器或内核数。 因此,线程安全单元格(或仅包含线程安全函数的单元格)可以分配给并发线程,但需遵循其先例后需要计算的常见重新计算逻辑。

Thread-Safe 函数

从 Excel 2007 开始的大多数内置工作表函数都是线程安全的。 还可以编写 XLL 函数并将其注册为线程安全。 Excel 使用一个线程 (其main线程) 调用所有命令、线程不安全函数、xlAutoFreexlAutoFree12 () 之外的 xlAuto 函数,以及 COM 和 Visual Basic for Applications (VBA) 函数。

如果 XLL 函数返回 xlbitDLLFreeXLOPER 或XLOPER12,则 Excel 将使用调用函数的同一线程来调用 xlAutoFreexlAutoFree12。 在该线程上的下一个函数调用之前,将调用 xlAutoFree 或 xlAutoFree12

对于 XLL 开发人员来说,创建线程安全函数有一些好处:

  • 它们允许 Excel 充分利用多处理器或多核计算机。

  • 它们提供了使用远程服务器比使用单个线程更高效的可能性。

假设你有一台单处理器计算机,该计算机已配置为使用 N 个线程 。 假设正在运行一个电子表格,该电子表格对 XLL 函数进行大量调用,该函数反过来又向远程服务器或服务器群集发送对数据或计算的请求。 根据依赖项树的拓扑,Excel 几乎可以同时调用函数 N 次。 如果服务器或服务器足够快或并行,电子表格的重新计算时间可以减少 1/N 倍。

编写线程安全函数的关键问题是正确处理资源的争用。 这通常意味着内存争用,可以分为两个问题:

  • 如何创建已知仅由此线程使用的内存。

  • 如何确保多个线程安全地访问共享内存。

首先需要注意的是 XLL 中的哪些内存可供所有线程访问,以及哪些内存只能由当前执行的线程访问。

可供所有线程访问

  • 在函数主体外部声明的变量、结构和类实例。

  • 在函数主体中声明的静态变量。

在这两种情况下,内存将放在为此 DLL 实例创建的 DLL 内存块中。 如果另一个应用程序实例加载 DLL,它将获取该内存的自己的副本,以便不会从 DLL 的此实例外部争用这些资源。

只能由当前线程访问

  • 函数代码中的自动变量 (包括函数参数) 。

在这种情况下,会将内存留出用于函数调用的每个实例的堆栈上。

注意

动态分配的内存的范围取决于指向它的指针的范围:如果指针可供所有线程访问,则内存也是。 如果指针是函数中的自动变量,则分配的内存实际上是该线程专用的。

只能由一个线程访问的内存:Thread-Local 内存

鉴于函数主体中的静态变量可供所有线程访问,使用它们的函数显然不是线程安全的。 一个线程上的函数的一个实例可能会更改值,而另一个线程上的另一个实例假设它完全不同。

在函数中声明静态变量有两个原因:

  1. 静态数据从一次调用保存到下一个调用。

  2. 函数可以安全地返回指向静态数据的指针。

对于第一个原因,你可能希望数据持久且对函数的所有调用都具有意义:可能是每次在任何线程上调用函数时都会递增的简单计数器,或者收集每次调用的使用情况和性能数据的结构。 问题是如何保护共享数据或数据结构。 最好使用关键部分来完成此操作,如下一部分所述。

如果数据仅供此线程使用(原因 1 的原因可能就是这种情况,并且原因 2 始终如此),那么问题是如何创建保留但只能从此线程访问的内存。 一种解决方案是使用线程本地存储 (TLS) API。

例如,假设有一个函数返回指向 XLOPER 的指针。

LPXLOPER12 WINAPI mtr_unsafe_example(LPXLOPER12 pxArg)
{
    static XLOPER12 xRetVal; // memory shared by all threads!!!
// code sets xRetVal to a function of pxArg ...
    return &xRetVal;
}

此函数不是线程安全的,因为一个线程可以返回静态 XLOPER12 而另一个线程正在覆盖它。 如果需要将 XLOPER12 传递到 xlAutoFree12,则发生这种情况的可能性更大。 一种解决方案是分配 XLOPER12,返回指向它的指针,并实现 xlAutoFree12 ,以便释放 XLOPER12 内存本身。 此方法在 Excel 中的内存管理中显示的许多示例函数中使用。

LPXLOPER12 WINAPI mtr_safe_example_1(LPXLOPER12 pxArg)
{
// pxRetVal must be freed later by xlAutoFree12
    LPXLOPER12 pxRetVal = new XLOPER12;
// code sets pxRetVal to a function of pxArg ...
    pxRetVal->xltype |= xlbitDLLFree; // Needed for all types
    return pxRetVal; // xlAutoFree12 must free this
}

与下一部分中概述的方法相比,此方法的实现更简单,该方法依赖于 TLS API,但它有一些缺点。 首先,无论返回的 XLOPER/ XLOPER12的类型如何,Excel 都必须调用 xlAutoFree/ xlAutoFree12。 其次,返回 XLOPER/ XLOPER12是调用 C API 回调函数的返回值时出现问题。 XLOPER/ XLOPER12可能指向需要由 Excel 释放的内存,但 XLOPER/ XLOPER12本身必须按照分配的相同方式释放。 如果要将此类 XLOPER/ XLOPER12 用作 XLL 工作表函数的返回值,则没有简单的方法来通知 xlAutoFree/ xlAutoFree12 需要以适当方式释放两个指针。 (设置 xlbitXLFreexlbitDLLFree 并不能解决此问题,因为 Excel 中 XLOPER/XLOPER12s 这两个集的处理是未定义的,并且可能会从版本更改为版本。) 为了解决此问题,XLL 可以生成所有 Excel 分配的 XLOPER/XLOPER12 的 深层副本,并将其返回到工作表。

避免这些限制的解决方案是填充并返回线程本地 XLOPER/XLOPER12,这种方法要求 xlAutoFree/xlAutoFree12 不释放 XLOPER/XLOPER12 指针本身。

LPXLOPER12 get_thread_local_xloper12(void);
LPXLOPER12 WINAPI mtr_safe_example_2(LPXLOPER12 pxArg)
{
    LPXLOPER12 pxRetVal = get_thread_local_xloper12();
// Code sets pxRetVal to a function of pxArg setting xlbitDLLFree or
// xlbitXLFree as required.
    return pxRetVal; // xlAutoFree12 must not free this pointer!
}

下一个问题是如何设置和检索线程本地内存,换句话说,如何实现上一示例中 get_thread_local_xloper12 函数。 这是使用线程本地存储 (TLS) API 完成的。 第一步是使用 TlsAlloc 获取 TLS 索引,该索引最终必须使用 TlsFree 发布。 两者都最好通过 DllMain 完成。

// This implementation just calls a function to set up
// thread-local storage.
BOOL TLS_Action(DWORD Reason); // Could be in another module
BOOL WINAPI DllMain(HINSTANCE hDll, DWORD Reason, void *Reserved)
{
    return TLS_Action(Reason);
}
DWORD TlsIndex; // Module scope only if all TLS access in this module
BOOL TLS_Action(DWORD DllMainCallReason)
{
    switch (DllMainCallReason)
    {
    case DLL_PROCESS_ATTACH: // The DLL is being loaded.
        if((TlsIndex = TlsAlloc()) == TLS_OUT_OF_INDEXES)
            return FALSE;
        break;
    case DLL_PROCESS_DETACH: // The DLL is being unloaded.
        TlsFree(TlsIndex); // Release the TLS index.
        break;
    }
    return TRUE;
}

获取索引后,下一步是为每个线程分配一个内存块。 Windows 开发文档建议每次使用 DLL_THREAD_ATTACH 事件调用 DllMain 回调函数时执行此操作,并释放每个DLL_THREAD_DETACH上的内存。 但是,遵循此建议会导致 DLL 对不用于重新计算的线程执行不必要的工作。

相反,最好使用“首次使用时分配”策略。 首先,需要定义要为每个线程分配的结构。 对于上述返回 XLOPER 或XLOPER12s 的示例,以下各项已足够,但可以创建满足需求的任何结构。

struct TLS_data
{
    XLOPER xloper_shared_ret_val;
    XLOPER12 xloper12_shared_ret_val;
// Add other required thread-local data here...
};

以下函数获取指向线程本地实例的指针,如果这是第一次调用,则分配一个指针。

TLS_data *get_TLS_data(void)
{
// Get a pointer to this thread's static memory.
    void *pTLS = TlsGetValue(TlsIndex);
    if(!pTLS) // No TLS memory for this thread yet
    {
        if((pTLS = calloc(1, sizeof(TLS_data))) == NULL)
        // Display some error message (omitted).
            return NULL;
        TlsSetValue(TlsIndex, pTLS); // Associate with this thread
    }
    return (TLS_data *)pTLS;
}

现在,可以看到如何获取线程本地 XLOPER/XLOPER12 内存:首先,获取指向线程 的 TLS_data 实例的指针,然后返回指向其中包含 的 XLOPER/XLOPER12 的指针,如下所示。

LPXLOPER get_thread_local_xloper(void)
{
    TLS_data *pTLS = get_TLS_data();
    if(pTLS)
        return &(pTLS->xloper_shared_ret_val);
    return NULL;
}
LPXLOPER12 get_thread_local_xloper12(void)
{
    TLS_data *pTLS = get_TLS_data();
    if(pTLS)
        return &(pTLS->xloper12_shared_ret_val);
    return NULL;
}

运行 Excel 时,可以将 mtr_safe_example_1mtr_safe_example_2 函数注册为线程安全的工作表函数。 但是,不能将这两种方法混合在一个 XLL 中。 XLL 只能导出 xlAutoFreexlAutoFree12 的一个实现,并且每个内存策略都需要不同的方法。 使用 mtr_safe_example_1,传递给 xlAutoFree/xlAutoFree12 的指针必须与它指向的任何数据一起释放。 使用 mtr_safe_example_2时,仅应释放指向的数据。

Windows 还提供了一个函数 GetCurrentThreadId,该函数返回当前线程的唯一系统范围 ID。 这为开发人员提供了另一种方法,使代码线程安全,或使其行为线程特定。

内存只能由多个线程访问:关键部分

应使用关键部分保护可由多个线程访问的读/写内存。 对于要保护的每个内存块,都需要一个命名的关键部分。 可以在调用 xlAutoOpen 函数期间初始化它们,并在调用 xlAutoClose 函数期间将其释放并设置为 null。 然后,需要在 对 EnterCriticalSection 和 LeaveCriticalSection 的一对调用中包含对受保护块 的每个访问权限。 任何时候都只允许一个线程进入关键部分。 下面是一个名为 g_csSharedTable 的分区的初始化、取消初始化和使用示例。

CRITICAL_SECTION g_csSharedTable; // global scope (if required)
bool xll_initialised = false; // Only module scope needed
int WINAPI xlAutoOpen(void)
{
    if(xll_initialised)
        return 1;
// Other initialisation omitted
    InitializeCriticalSection(&g_csSharedTable);
    xll_initialised = true;
    return 1;
}
int WINAPI xlAutoClose(void)
{
    if(!xll_initialised)
        return 1;
// Other cleaning up omitted.
    DeleteCriticalSection(&g_csSharedTable);
    xll_initialised = false;
    return 1;
}
#define SHARED_TABLE_SIZE 1000 /* Some value consistent with the table */
bool read_shared_table_element(unsigned int index, double &d)
{
    if(index >= SHARED_TABLE_SIZE) return false;
    EnterCriticalSection(&g_csSharedTable);
    d = shared_table[index];
    LeaveCriticalSection(&g_csSharedTable);
    return true;
}
bool set_shared_table_element(unsigned int index, double d)
{
    if(index >= SHARED_TABLE_SIZE) return false;
    EnterCriticalSection(&g_csSharedTable);
    shared_table[index] = d;
    LeaveCriticalSection(&g_csSharedTable);
    return true;
}

保护内存块的另一种可能更安全的方法是创建一个类,该类包含其自己的 CRITICAL_SECTION ,其构造函数、析构函数和访问器方法负责其使用。 此方法的附加优势在于保护可能在 运行 xlAutoOpen 之前初始化的对象,或者在调用 xlAutoClose 后继续存在这些对象,但应注意创建过多的关键节和由此产生的操作系统开销。

如果代码需要同时访问多个受保护内存块,则需要非常仔细地考虑关键部分的进入和退出顺序。 例如,以下两个函数可能会造成死锁。

// WARNING: Do not copy this code. These two functions
// can produce a deadlock and are provided for
// example and illustration only.
bool copy_shared_table_element_A_to_B(unsigned int index)
{
    if(index >= SHARED_TABLE_SIZE) return false;
    EnterCriticalSection(&g_csSharedTableA);
    EnterCriticalSection(&g_csSharedTableB);
    shared_table_B[index] = shared_table_A[index];
// Critical sections should be exited in the order
// they were entered, NOT as shown here in this
// deliberately wrong illustration.
    LeaveCriticalSection(&g_csSharedTableA);
    LeaveCriticalSection(&g_csSharedTableB);
    return true;
}
bool copy_shared_table_element_B_to_A(unsigned int index)
{
    if(index >= SHARED_TABLE_SIZE) return false;
    EnterCriticalSection(&g_csSharedTableB);
    EnterCriticalSection(&g_csSharedTableA);
    shared_table_A[index] = shared_table_B[index];
    LeaveCriticalSection(&g_csSharedTableA);
    LeaveCriticalSection(&g_csSharedTableB);
    return true;
}

如果一个线程上的第一个函数进入 g_csSharedTableA 而另一个线程上的第二个函数进入 g_csSharedTableB,则两个线程都挂起。 正确的方法是按一致的顺序输入,然后按相反的顺序退出,如下所示。

    EnterCriticalSection(&g_csSharedTableA);
    EnterCriticalSection(&g_csSharedTableB);
    // code that accesses both blocks
    LeaveCriticalSection(&g_csSharedTableB);
    LeaveCriticalSection(&g_csSharedTableA);

如果可能,最好从线程合作的角度来隔离对不同块的访问,如下所示。

bool copy_shared_table_element_A_to_B(unsigned int index)
{
    if(index >= SHARED_TABLE_SIZE) return false;
    EnterCriticalSection(&g_csSharedTableA);
    double d = shared_table_A[index];
    LeaveCriticalSection(&g_csSharedTableA);
    EnterCriticalSection(&g_csSharedTableB);
    shared_table_B[index] = d;
    LeaveCriticalSection(&g_csSharedTableB);
    return true;
}

如果共享资源存在大量争用(例如频繁的短持续时间访问请求),应考虑使用关键部分的旋转功能。 这是一种使等待资源减少处理器密集型的技术。 为此,可以在初始化节时使用 InitializeCriticalSectionAndSpinCountSetCriticalSectionSpinCount 在初始化后设置线程循环的次数,然后再等待资源变为可用。 等待操作成本高昂,因此,如果同时释放资源,则旋转操作会避免这种情况。 在单个处理器系统上,旋转计数实际上被忽略,但你仍然可以指定它,而不会造成任何伤害。 内存堆管理器使用自旋计数 4000。 有关使用关键部分的详细信息,请参阅Windows SDK文档。

另请参阅

Excel 中的内存管理

Excel 中的多线程重新计算

加载项管理器和 XLL 接口函数