使用 XLL 技术开发 Excel 插件时的,多线程技术。
线程安全函数
Excel2007 中的大多数工作表函数都是线程安全的。你也可以自己创建线程安全函数。Excel 2007 使用单线程调用“Excel命令”和“非线程安全函数”、“xlAuto 函数”(除了 xlAutoFree 和 xlAutoFree12)、“COM”、“VBA函数”。
当一个XLL函数返回 使用了 xlbitDLLFree 选项的 XLOPER 或 XLOPER12 时,Excel使用调用XLL函数的同一线程去访问 xlAtuoFree或 xlAutoFree12函数。这些调用会在同一线程下次函数调用之前执行。
对于XLL开发人员来说,创建线程安全函数,有如下好处:
- 可以充分发利用多处理器或多核计算机的计算能力
- 相对单线程,多线程能更有效的使用远程服务。
假设你使用的是单处理器计算机,设置并打了多线程处理选项,线程数为N。假设,电子表格软件使用XLL函数处理了大量的单元格数据,它依次发送数据请求或远程服务请求。受限于依赖树拓扑结构,Excel 几乎会同时调用此函数N次。多线程使计算过程足够快速以及拥有并发处理能力,电子表的重计算时间可能会因此减少到 1/N。
使用多线程关键点是需要处理好内存的争用问题。这往往意味着,内存争用包含了下面两个问题:
- 如果创建一个只能由这一个线程使用的内存。
- 如何确保同享内存的多线程访问安全性问题。
我们首先需要注意的是那些内存是可以被 所有线程 访问的,那些内存是只能被 当前线程 访问的。
所有线程访问的内存
- 声明在函数体外的 变量、结构、类实例
- 函数体内的静态变量
以上两个种情况,都是为某一个DLL实例创建的内存块,如果其它的应用程序载入这个DLL,就会另外创建内存,所以两个DLL实例之间不会发生内存争用的问题。
当前线程访问的内存
- 函数代码内的自动内存
这种情况下,内存在每个函数实例的堆栈上。
单线程内存访问:线程本地内存
考虑到函数体内的静态内存,可以被所有线程访问,此函数显示不是线程安全的。一个线程上的一个函数实例,可以改变这个值,而另外一个线程上的另一个函数会假设它是完全不同的东西。
有两个理由让我们需要在函数体内设置静态变量:
- 需要在多次调用之间保存数值。
- 这个函数可以安全的返回静态数据指针。
对于第一个原因,你可能需要为所有的函数调用保留数据:比如一个调用计数器,每次调用计数器都会加1,或是收集每次调用函数性能数据。这里需要关注的问题是如何 保护共享数据或数据结构。最好的作法是使用下节介绍的临界段。
如果数据只打算用于单个线程,存在的问题是如何保留一个只能由单线程访问的数据。一种解决方案是使用线程本地存储方案 (TLS thread-local storage)API。
以下实例中,函数返回了一个 XLOPER 指针。
LPXLOPER12 WINAPI mtr_unsafe_example(LPXLOPER12 pxArg)
{
static XLOPER12 xRetVal; // 内存共享于所有线程!!!
// code sets xRetVal to a function of pxArg ...
return &xRetVal;
}
这个函数不是线程安全的,因为当一个线程返回 XLOPER12 时,另外一个线程会再次返回并重写原有值。如果需要传递 XLOPER12 给 xlAutoFree12,发生重写的可能性是很大的。一种解决方法是为每个线程都分配一个 XLOPER12 ,并在实现 xlAutoFree12 时使用 XLOPER12 的实例指针,这样就只会释放自己实例。在我们提供的实例中很多时候都是使用的这种方法。
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 来释放内存,其次,当调用C API回调函数返回XLOPER/XLOPER12s,也会存在一些问题。XLOPER/XLOPER12 指向的内存可能需要由Excel释放,但是 XLOPER/XLOPER12 通常必需使用当初分配内存时,相对应的内存释放方式来释放内存空间 。如果这类 XLOPER/XLOPER12 用作 XLL 工作表函数的返回值,那么就没有合适的方法去通知 xlAutoFree/xlAutoFree12 使用合适的方法释放 它们。为了解决这一问题,XLL 可以使用深拷贝 Excel分配的 XLOPER/XLOPER12s 。
以个避免以上问题的方法是填入和返回本地线程 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 函数。这里通过使用 线程本地存储API(TLS)。第一步是使用 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 SDK Dynamic-Link Library Reference 中推荐的方法是使用 DLL_THREAD_ATTACH 。使用 DLL_THREAD_DETACH 释放。然而这个建议会导致你的DLL执行的一些操作和 计算是没有关系的。
替代它的最好方法是在第一次使用时分配内存。首先你需要为每个线程定义一个结构。对于上一个例子中中返回的 XLOPER 或 XLOPER12 使用下面的方法就足够了。但你可以创建其他的结构,满足你的需要。
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;
}
mtr_safe_example_1 和 mtr_safe_example_2 当你运行 Excel 2007 时可以注册为线程安全函数。然后,你不能在一个XLL中混合两种广场。你的XLL只能输出xlAutoFree 和 xlAutoFree12 的一种实现,每个内存管理方式都需要不同的方法。 mtr_safe_example_1 指针传递给 xlAutoFree/xlAutoFree12,必需释放任何指针它的指针。而 mtr_safe_example_2 只有指向的数据结构需要释放。
Windows 还支持函数 GetCurrentThreadId ,它可以返回当前线程唯一ID,支持开发者使用其它的方法编码线程安全函数,或特定的线程行为。
内存由多个线程访问处理方法:临界段
你可以使用临界段来解决多个线程争用内存的情况。你需要为每个内存块临界段进行命名,这些操作你可以在调用函数 xlAutoOpen 时执行,在调用函数 xlAutoClose 时释放它们。然后,你需要在调用EnterCriticalSection 或 LeaveCriticalSection 包含每个访问的被保护的内存块。一次只能允许一个线程访问被保护的内存块。
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;
}