Multithreading et contention de mémoire dans Excel
S’applique à: Excel 2013 | Office 2013 | Visual Studio
Les versions de Microsoft Excel antérieures à Excel 2007 utilisent un thread unique pour tous les calculs de feuille de calcul. Toutefois, à compter d’Excel 2007, Excel peut être configuré pour utiliser de 1 à 1024 threads simultanés pour le calcul de feuille de calcul. Sur un ordinateur multiprocesseur ou multicœur, le nombre par défaut de threads est égal au nombre de processeurs ou de cœurs. Par conséquent, les cellules thread-safe, ou les cellules qui contiennent uniquement des fonctions thread-safe, peuvent être allouées à des threads simultanés, sous réserve de la logique de recalcul habituelle de la nécessité d’être calculées après leurs précédents.
fonctions Thread-Safe
La plupart des fonctions de feuille de calcul intégrées à partir d’Excel 2007 sont thread-safe. Vous pouvez également écrire et inscrire des fonctions XLL comme étant thread-safe. Excel utilise un thread (son thread main) pour appeler toutes les commandes, fonctions non sécurisées de thread, fonctions xlAuto (à l’exception de xlAutoFree et xlAutoFree12) et fonctions COM et Visual Basic pour Applications (VBA).
Lorsqu’une fonction XLL retourne un XLOPER ou XLOPER12 avec xlbitDLLFree défini, Excel utilise le même thread sur lequel l’appel de fonction a été effectué pour appeler xlAutoFree ou xlAutoFree12. L’appel à xlAutoFree ou xlAutoFree12 est effectué avant l’appel de fonction suivant sur ce thread.
Pour les développeurs XLL, la création de fonctions thread-safe présente des avantages :
Ils permettent à Excel de tirer le meilleur parti d’un ordinateur multiprocesseur ou multicœur.
Ils ouvrent la possibilité d’utiliser des serveurs distants beaucoup plus efficacement qu’avec un seul thread.
Supposons que vous disposez d’un ordinateur monoprocesseur configuré pour utiliser, par exemple, N threads. Supposons qu’une feuille de calcul en cours d’exécution effectue un grand nombre d’appels à une fonction XLL qui, à son tour, envoie une demande de données ou un calcul à effectuer à un serveur distant ou à un cluster de serveurs. Sous réserve de la topologie de l’arborescence de dépendances, Excel peut appeler la fonction N fois presque simultanément. À condition que le serveur ou les serveurs soient suffisamment rapides ou parallèles, le temps de recalcul de la feuille de calcul peut être réduit d’un facteur 1/N.
Le principal problème lors de l’écriture de fonctions thread-safe est de gérer correctement la contention des ressources. Cela signifie généralement une contention de mémoire, et elle peut être divisée en deux problèmes :
Comment créer de la mémoire qui, vous savez, sera utilisée uniquement par ce thread.
Comment s’assurer que la mémoire partagée est accessible en toute sécurité par plusieurs threads.
La première chose à savoir est quelle mémoire dans un XLL est accessible par tous les threads, et ce qui est uniquement accessible par le thread en cours d’exécution.
Accessible par tous les threads
Variables, structures et instances de classe déclarées en dehors du corps d’une fonction.
Variables statiques déclarées dans le corps d’une fonction.
Dans ces deux cas, la mémoire est mise de côté dans le bloc de mémoire de la DLL créé pour cette instance de la DLL. Si une autre application instance charge la DLL, elle obtient sa propre copie de cette mémoire afin qu’il n’y ait aucune contention pour ces ressources en dehors de cette instance de la DLL.
Accessible uniquement par le thread actuel
- Variables automatiques dans le code de fonction (y compris les arguments de fonction).
Dans ce cas, la mémoire est mise de côté sur la pile pour chaque instance de l’appel de fonction.
Remarque
L’étendue de la mémoire allouée dynamiquement dépend de l’étendue du pointeur qui pointe vers elle : si le pointeur est accessible par tous les threads, la mémoire l’est également. Si le pointeur est une variable automatique dans une fonction, la mémoire allouée est en fait privée pour ce thread.
Mémoire accessible par un seul thread : mémoire Thread-Local
Étant donné que les variables statiques dans le corps d’une fonction sont accessibles par tous les threads, les fonctions qui les utilisent ne sont clairement pas thread-safe. Une instance de la fonction sur un thread peut être la modification de la valeur tandis qu’un autre instance sur un autre thread suppose qu’il s’agit de quelque chose de complètement différent.
Il existe deux raisons pour déclarer des variables statiques dans une fonction :
Les données statiques sont conservées d’un appel à l’autre.
Un pointeur vers des données statiques peut être retourné en toute sécurité par la fonction.
Dans le cas de la première raison, vous souhaiterez peut-être avoir des données qui persistent et ont une signification pour tous les appels à la fonction : peut-être un compteur simple qui est incrémenté chaque fois que la fonction est appelée sur n’importe quel thread, ou une structure qui collecte les données d’utilisation et de performances sur chaque appel. La question est de savoir comment protéger les données partagées ou la structure de données. Pour ce faire, il est préférable d’utiliser la section critique comme l’explique la section suivante.
Si les données sont destinées uniquement à être utilisées par ce thread, ce qui peut être le cas pour la raison 1 et est toujours le cas pour la raison 2, la question est de savoir comment créer une mémoire qui persiste mais qui est accessible uniquement à partir de ce thread. Une solution consiste à utiliser l’API de stockage local thread (TLS).
Prenons l’exemple d’une fonction qui retourne un pointeur vers un 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;
}
Cette fonction n’est pas thread-safe, car un thread peut retourner le XLOPER12 statique tandis qu’un autre est en cours de remplacement. La probabilité que cela se produise est encore plus grande si le XLOPER12 doit être passé à xlAutoFree12. Une solution consiste à allouer un XLOPER12, à y retourner un pointeur et à implémenter xlAutoFree12 afin que la mémoire XLOPER12 elle-même soit libérée. Cette approche est utilisée dans la plupart des exemples de fonctions présentés dans Gestion de la mémoire dans 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
}
Cette approche est plus simple à implémenter que l’approche décrite dans la section suivante, qui s’appuie sur l’API TLS, mais présente certains inconvénients. Tout d’abord, Excel doit appeler xlAutoFree/ xlAutoFree12 quel que soit le type duXLOPER12XLOPER/ retourné. Deuxièmement, il existe un problème lors du renvoi desXLOPER12XLOPER/ qui sont la valeur de retour d’un appel à une fonction de rappel d’API C. LeXLOPER12XLOPER/ peut pointer vers la mémoire qui doit être libérée par Excel, mais leXLOPER12XLOPER/ lui-même doit être libéré de la même façon qu’il a été alloué. Si une telleXLOPER12XLOPER/ doit être utilisée comme valeur de retour d’une fonction de feuille de calcul XLL, il n’existe aucun moyen simple d’informer xlAutoFree/ xlAutoFree12 de la nécessité de libérer les deux pointeurs de la manière appropriée. (La définition de xlbitXLFree et xlbitDLLFree ne résout pas le problème, car le traitement de XLOPER/XLOPER12s dans Excel avec les deux ensembles n’est pas défini et peut changer de version en version.) Pour contourner ce problème, le XLL peut effectuer des copies détaillées de tous les XLOPER/XLOPER12 alloués par Excel qu’il retourne dans la feuille de calcul.
Une solution qui évite ces limitations consiste à remplir et à retourner un thread local XLOPER/XLOPER12, une approche qui nécessite que xlAutoFree/xlAutoFree12 ne libère pas le pointeur XLOPER/XLOPER12 lui-même.
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!
}
La question suivante est comment configurer et récupérer la mémoire locale du thread, en d’autres termes, comment implémenter la fonction get_thread_local_xloper12 dans l’exemple précédent. Cette opération s’effectue à l’aide de l’API De stockage local thread (TLS). La première étape consiste à obtenir un index TLS à l’aide de TlsAlloc, qui doit finalement être publié à l’aide de TlsFree. Les deux sont mieux réalisés à partir de 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;
}
Une fois l’index obtenu, l’étape suivante consiste à allouer un bloc de mémoire pour chaque thread. La documentation de développement Windows recommande d’effectuer cette opération chaque fois que la fonction de rappel DllMain est appelée avec un événement DLL_THREAD_ATTACH et libère la mémoire sur chaque DLL_THREAD_DETACH. Toutefois, si vous suivez ce conseil, votre DLL effectuerait un travail inutile pour les threads non utilisés pour le recalcul.
Au lieu de cela, il est préférable d’utiliser une stratégie d’allocation en premier usage. Tout d’abord, vous devez définir une structure que vous souhaitez allouer pour chaque thread. Pour les exemples précédents qui retournent xlOPERs ou XLOPER12s, les éléments suivants suffisent, mais vous pouvez créer n’importe quelle structure qui répond à vos besoins.
struct TLS_data
{
XLOPER xloper_shared_ret_val;
XLOPER12 xloper12_shared_ret_val;
// Add other required thread-local data here...
};
La fonction suivante obtient un pointeur vers le instance local du thread ou en alloue un s’il s’agit du premier appel.
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;
}
Vous pouvez maintenant voir comment la mémoire XLOPER/XLOPER12 locale du thread est obtenue : tout d’abord, vous obtenez un pointeur vers le instance du thread de TLS_data, puis vous retournez un pointeur vers le XLOPER/XLOPER12 qu’il contient, comme suit.
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;
}
Les fonctions mtr_safe_example_1 et mtr_safe_example_2 peuvent être inscrites en tant que fonctions de feuille de calcul thread-safe lorsque vous exécutez Excel. Toutefois, vous ne pouvez pas combiner les deux approches dans un xll. Votre XLL ne peut exporter qu’une seule implémentation de xlAutoFree et xlAutoFree12, et chaque stratégie de mémoire nécessite une approche différente. Avec mtr_safe_example_1, le pointeur passé à xlAutoFree/xlAutoFree12 doit être libéré avec toutes les données qu’il pointe vers. Avec mtr_safe_example_2, seules les données pointées doivent être libérées.
Windows fournit également une fonction GetCurrentThreadId, qui retourne l’ID unique du thread actuel à l’échelle du système. Cela fournit au développeur un autre moyen de sécuriser le thread de code ou de rendre son thread de comportement spécifique.
Mémoire accessible uniquement par plusieurs threads : sections critiques
Vous devez protéger la mémoire en lecture/écriture accessible à plusieurs threads à l’aide de sections critiques. Vous avez besoin d’une section critique nommée pour chaque bloc de mémoire que vous souhaitez protéger. Vous pouvez les initialiser pendant l’appel à la fonction xlAutoOpen , les libérer et les définir sur null pendant l’appel à la fonction xlAutoClose . Vous devez ensuite contenir chaque accès au bloc protégé dans une paire d’appels à EnterCriticalSection et LeaveCriticalSection. Un seul thread est autorisé dans la section critique à tout moment. Voici un exemple d’initialisation, de non-initialisation et d’utilisation d’une section appelée 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;
}
Une autre façon peut-être plus sûre de protéger un bloc de mémoire consiste à créer une classe qui contient ses propres CRITICAL_SECTION et dont les méthodes de constructeur, de destructeur et d’accesseur s’occupent de son utilisation. Cette approche présente l’avantage supplémentaire de protéger les objets qui peuvent être initialisés avant l’exécution de xlAutoOpen ou survivre après l’appel de xlAutoClose , mais vous devez être prudent lorsque vous créez trop de sections critiques et la surcharge du système d’exploitation que cela créerait.
Lorsque vous avez du code qui a besoin d’accéder à plusieurs blocs de mémoire protégée en même temps, vous devez examiner très attentivement l’ordre dans lequel les sections critiques sont entrées et sorties. Par exemple, les deux fonctions suivantes peuvent créer un interblocage.
// 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;
}
Si la première fonction sur un thread entre g_csSharedTableA tandis que la seconde sur un autre thread entre g_csSharedTableB, les deux threads se bloquent. L’approche correcte consiste à entrer dans un ordre cohérent et à quitter dans l’ordre inverse, comme suit.
EnterCriticalSection(&g_csSharedTableA);
EnterCriticalSection(&g_csSharedTableB);
// code that accesses both blocks
LeaveCriticalSection(&g_csSharedTableB);
LeaveCriticalSection(&g_csSharedTableA);
Dans la mesure du possible, il est préférable, du point de vue de la coopération des threads, d’isoler l’accès aux blocs distincts, comme illustré ici.
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;
}
Lorsqu’il y a beaucoup de conflits pour une ressource partagée, comme des demandes d’accès de courte durée fréquentes, vous devez envisager d’utiliser la capacité de la section critique à tourner. Il s’agit d’une technique qui rend l’attente de la ressource moins gourmande en processeur. Pour ce faire, vous pouvez utiliser InitializeCriticalSectionAndSpinCount lors de l’initialisation de la section ou SetCriticalSectionSpinCount une fois initialisée, pour définir le nombre de boucles de thread avant d’attendre que les ressources soient disponibles. L’opération d’attente étant coûteuse, le fait de tourner évite cela si la ressource est libérée entre-temps. Sur un système à processeur unique, le nombre de spins est effectivement ignoré, mais vous pouvez toujours le spécifier sans causer de dommages. Le gestionnaire de tas de mémoire utilise un nombre de spins de 4 000. Pour plus d’informations sur l’utilisation des sections critiques, consultez la documentation SDK Windows.
Voir aussi
Gestion de la mémoire dans Excel
Recalcul multithread dans Excel
Fonctions du Gestionnaire de compléments et de l’interface XLL