Context switch et switch context

Vendredi, septembre 23, 2016
By David Baffaleuf in SQL Server (dbaffaleuf@capdata-osmozium.com) [70 article(s)]

Pour alimenter le débat, la définition de ces 2 colonnes de sys.dm_os_schedulers :

- context_switches_count Number of context switches that have occurred on this scheduler. Is not nullable. To allow for other workers to run, the current running worker has to relinquish control of the scheduler or switch context.  Note: If a worker yields the scheduler and puts itself into the runnable queue and then finds no other workers, the worker will select itself. In this case, the context_switches_count is not updated, but the yield_count is updated.

preemptive_switches_count : Number of times that workers on this scheduler have switched to the preemptive mode. To execute code that is outside SQL Server (for example, extended stored procedures and distributed queries), a thread has to execute outside the control of the non-preemptive scheduler. To do this, a worker switches to preemptive mode.

Changement de contexte et préemption :

Les problèmes de scheduling vous les côtoyez peut être sans le savoir toutes les semaines à la caisse du supermarché :

  • Vous êtes dans la file d’attente d’une caisse prioritaire.
  • La caissière a déjà passé devant le lecteur une demi-douzaine de vos articles lorsqu’une femme enceinte arrive derrière vous avec son caddie rempli d’articles. Vous êtes un gentleman à n’en pas douter, donc vous la laissez passer devant vous. (1)
  • Lorsqu’elle a fini d’encaisser la femme enceinte, la caissière peut enfin terminer de passer vos articles devant le lecteur.
  • Lors du passage d’un des derniers articles, le code barre n’est pas lisible et la caissière doit demander par téléphone un prix à un autre employé qui peut se déplacer dans les rayons. Vous êtes mis en attente
  • Il y a un problème avec le code barre en rayon, et la personne dépêchée sur place ne parvient pas non plus à déterminer le prix. S’il se passe trop de temps (2) pour récupérer le prix, la caissière peut vous mettre en attente sur le côté et  commencer à traiter la personne qui se trouvait derrière vous dans la file d’attente. (3)


(1) : La femme enceinte passe devant vous à la caisse parce que sa priorité de passage est supérieure à la vôtre. C’est une préemption. Vous ne repartez pas au fond de la file d’attente, vous conservez votre rang de passage à votre niveau de priorité. S’il n’y a pas d’autre personne bénéficiant d’un passage prioritaire à la caisse, vous êtes toujours la prochaine personne sur la liste. A cet instant, la caissière créé une nouvelle séquence d’encaissement après avoir sauvegardé la vôtre, pour pouvoir revenir à l’article correspondant au moment où la femme enceinte est passé devant vous. C’est un changement de contexte. Les deux notions interviennent donc dans la même séquence.

(2) : La valeur de temps a son importance, nous l’appellerons quantième ou quantum en anglais.

(3) : Vous bloquez donc sur une ressource particulière qui est le prix de l’article. Pour ne pas pénaliser les clients de même priorité derrière vous, la caissière vous remet en attente et commence à encaisser une autre personne. Elle recréé une nouvelle séquence d’encaissement après avoir sauvegardé la vôtre. C’est à nouveau un changement de contexte.

Deux choses à retenir de cet exemple :

-  Une préemption implique un changement de contexte. En revanche, un changement de contexte peut survenir dans d’autres cas que des différences de priorité, comme par exemple l’expiration d’un délai imparti ou le blocage sur une ressource.

-  L’un comme l’autre vous affectent car globalement ils rallongent votre temps de passage en caisse.

On l’aura compris ces notions existent pour garantir une plus grande équité sur l’utilisation des ressources qui sont partagées par plusieurs utilisateurs. Chaque personne de même priorité doit pouvoir accéder aux services de caisse dans les mêmes conditions et délais, autant que possible.

Partage du temps CPU :

La raison d’être de ces mécanismes vient du déséquilibre permanent qui existe entre les ressources et les clients de ces ressources. Il n’y qu’une seule caisse pour 5 ou 6 clients qui attendent dans la file. Il faut donc pouvoir accorder le même temps de passage à chaque client.

Combien d’applications sont démarrées sur votre PC ?

PS C:\Users\dbaffaleuf> get-process | measure-object
Count : 143
Average :
Sum :
Maximum :
Minimum :
Property :

143 processus, pour combien de processeurs ? Dans mon cas, seulement 4.

On l’a évopqué brièvement, il existe une notion supplémentaire pour parfaire encore ce principe d’équité (fairness): le quantième ou quantum. Sur Windows, c’est un bail de temps qui est octroyé à chaque thread de même priorité pour s’exécuter. Au-delà de ce temps, le thread sera context-switché pour un autre thread de priorité équivalente. Lorsque cela arrive, le thread qui a été interrompu retourne au fond de la file d’attente Ready to Run. Il est en quelque sorte ‘puni’, et paye le prix d’avoir utilisé tout son quantième de temps.

Dans l’exemple, lorsque la caissière ne parvient pas à déterminer le prix de l’article manquant dans le quantième imparti, elle vous met en attente pour ne pas bloquer le client suivant. La différence avec Windows, c’est que vous ne repartez pas à la fin de la file avec les articles qui vous restent. Le quantième rend les choses plus équitables encore, car cela empêche un seul client de monopoliser le temps de passage en caisse.

Sur la version cliente de windows, le quantum est vérifié tous les 2 cycles d’horloge, sur la version serveur, tous les 12 cycles. Plus il y a de temps pour exécuter une tâche et plus on minimise nos fameux changements de contexte. La véritable valeur du quantum est fonction de la résolution de la fréquence d’horloge sur le système et du nombre de cycle CPU par seconde. Le passage de l’un à  l’autre est modifiable via les propriétés de performance du système :

 

 

 

 

 

 

- Meilleures performances pour les programmes = 2 cycles d’horloge.

- Meilleures performances pour les services d’arrière-plan = 12 cycles.

Coût d’un changement de contexte :

La majeure partie du code de Windows est écrite en C, certaines parties en C++. Plus rarement, on trouvera des parties en assembleur, souvent pour optimiser encore les performances à l’exécution et minimiser le coût en cycles CPU. La routine de changement de contexte fait partie de ces cas de figure.

Tout d’abord, pour les threads (donc hormis fibers et UMS threads), un changement de contexte se déroule en kernel mode. A chaque fois, le pointeur d’instruction (la fonction en cours d’exécution) et le pointeur de la pile d’exécution sont sauvegardés avec un troisième pointeur vers l’espace d’adressage du processus père. Puis ces mêmes informations sont restaurées pour le thread qui entre en exécution.

En outre, la structure qui décrit l’état courant du dispatcher doit être mise à jour car un nouveau thread va être planifié. Cela nécessite une élévation du niveau d’interruption vers DPC/Dispatch et avant Windows 7, un coûteux accès au dispatcher lock (qui n’est plus systématique depuis grâce au travail d’Arun Kishan).

Tout ce temps, même s’il est optimisé via du code très spécialisé, représente tout de même des cycles CPU qui ne sont pas productifs.  Il faut donc essayer de les minimiser autant que possible.

Exemple d’une surcharge CPU:

Ci-dessous un petit bout de code simple qui va permettre de montrer ce qui se passe lorsque que l’on lance plus de tâches de même niveau de priorité qu’il n’y a de CPU disponibles.

// contextswitchdemo.cpp : Defines the entry point for the console application.
// a x64 based code so affinity is not capped to 32 processors as on syswow64 / 32 bits
// ref : https://msdn.microsoft.com/en-us/library/aa384228(v=VS.85).aspx 
//		https://blogs.msdn.microsoft.com/ddperf/2010/04/04/measuring-processor-utilization-and-queuing-delays-in-windows-applications/
//		https://msdn.microsoft.com/query/dev11.query?appId=Dev11IDEF1&l=EN-US&k=k(vs.cv.threads.timeline.preemption)&rd=true
//		https://technet.microsoft.com/en-us/library/cc938613.aspx 
//

#include "stdafx.h"
#include "stdio.h"
#include "stdlib.h"
#include "windows.h"
#include "process.h"
#include "math.h"

#define SUMTOTAL 3000000000 //Total of each summation
//#define __DEBUG

struct arglist {
	int			thid;
	DWORD_PTR	affinity;
};

HANDLE *events;
HANDLE *threads;
HANDLE privheap;

// THREAD ENTRY POINT ------------------------------------------------------------------------------
unsigned __stdcall justDoIt(LPVOID list)
{
	int i = 0;
	HANDLE thhandle;
	DWORD DEBTH, FINTH;

	thhandle = GetCurrentThread();
	DEBTH=GetTickCount();

	//Get info from the list struct --
	arglist * curlist = (arglist *)list;

	// Set affinity for the thread
	if (!SetThreadAffinityMask (thhandle, curlist->affinity))
	{
		printf("Failed to set affinity mask for thid : %d, Error : (%d)\n", curlist->thid, GetLastError());
	}

	if (!SetThreadPriorityBoost(thhandle,TRUE))
	{
		printf("Failed to disable priorty boost for thread id %d, Error : (%d)\n", curlist->thid, GetLastError());
	}

	if (!SetThreadPriority(thhandle,THREAD_PRIORITY_ABOVE_NORMAL))
	{
		printf("Failed to raise priority for thid : %d, Error : (%d)\n", curlist->thid, GetLastError());
	}

	printf("Thread id (%d) created with priority %d, affinity mask : %f\n",curlist->thid, GetThreadPriority(thhandle), (DOUBLE) curlist->affinity);

	while(i < sumtotal ) 	
	{
		i++; 	
	} 	
	FINTH=GetTickCount(); 	
	printf("Thread id (%d) Done in %d ms!\n",curlist->thid, FINTH-DEBTH);

	SetEvent(events[curlist->thid]);
	return 0;
}

// MAIN --------------------------------------------------------------------------------------------
int _tmain(int argc, TCHAR* argv[])
{
	__int64 numberOfThreads=1;
	int i;
	DWORD DEBUT, FIN;
	unsigned ThreadID;
	SYSTEM_INFO mysysteminfo;
	int CPUs;

	//argument = numberOfThreads
	if (argc != 2) {
		printf("Usage : contextswitchdemo < nb of concurrent threads>\n");
		return(0);
	} else {
		numberOfThreads = _wtoi64 (argv[1]);
	}
	#ifdef __DEBUG 
		printf ("Number of Threads is : %d\n",numberOfThreads);
	#endif

	// Allocate for events table on a private heap so it is clean -------------------------------------
	privheap	= HeapCreate(HEAP_GENERATE_EXCEPTIONS,0,0);
	events		= (HANDLE *) HeapAlloc(privheap,HEAP_ZERO_MEMORY,numberOfThreads * sizeof(HANDLE *));
	threads		= (HANDLE *) HeapAlloc(privheap,HEAP_ZERO_MEMORY,numberOfThreads * sizeof(HANDLE *));

	// Retrieve the number of CPU on the box
	GetSystemInfo(&mysysteminfo);
	CPUs=mysysteminfo.dwNumberOfProcessors;

	// First we need to disable dynamic priority boosting, --------------------------------------------
	// as we want to keep our hands on this
	if (!SetProcessPriorityBoost(GetCurrentProcess(),TRUE))
	{
		printf("Failed to disable dynamic priority boost (%d)\n", GetLastError());
	}

	DEBUT=GetTickCount();

	// L O O P ----------------------------------------------------------------------------------------
	for (i=0;i < numberOfThreads;i++) 	
	{ 		
		arglist *newarglist = new arglist; 		
		DWORD_PTR thaffinity; 		
		int thpriority; 		

		// compute affinity based on i^[nb CPUs] and pass to the entry point 		
		// Example for 4 CPUs : 		
		// 		
		// 1th :	1^4 -> 1
		// 2th :	1^4 -> 1, 2^4 -> 2
		// 3th :	1^4 -> 1, 2^4 -> 2, 3^4 -> 3
		// 4th :	1^4 -> 1, 2^4 -> 2, 3^4 -> 3, 4^4 -> 0
		// 5th :	1^4 -> 1, 2^4 -> 2, 3^4 -> 3, 4^4 -> 0
		//			5^4 -> 1
		// ...
		// 8th :	1^4 -> 1, 2^4 -> 2, 3^4 -> 3, 4^4 -> 0
		//			5^4 -> 1, 6^4 -> 2, 7^4 -> 3, 8^4 -> 0
		// etc...
		// 
		thaffinity= (DWORD_PTR) pow(2.0,i%CPUs);

		// update the list  struct
		newarglist->thid=i;
		newarglist->affinity=thaffinity;	

		// create event / thread pair 
		events[i]	= CreateEvent(NULL,false,false,NULL);
		threads[i]	= (HANDLE) _beginthreadex(NULL,0,justDoIt,newarglist,0,&ThreadID);

		if (!threads[i])
		{
			printf("Fatal: could not create thread, errno:%d\n",GetLastError()) ;
			return(3);
		} 		
	}

	// The main thread waits for all threads to finish and exits-----------------------------------------------------------------------------
	WaitForMultipleObjects(numberOfThreads,events,true,INFINITE);
	FIN=GetTickCount();
	printf("%d threads, all sums done in %d ms\n",numberOfThreads,(int)FIN-DEBUT);

	//Clean --------------------------------------------------------------------------------------------------------------------------------
	HeapDestroy(privheap);

	return 0;
}

Les aspects importants:

- Le programme prend en argument le nombre de threads à exécuter en parallèle: 1,2,3,4, 5, etc…
- Chaque thread exécute une somme d’entiers jusqu’à 3 milliards.
- Le niveau de priorité des threads est élevé par rapport à la priorité de base pour qu’ils ne soient en compétition qu’entre eux pour l’accès aux ressources.
- L’affinité est appliquée en round-robin : le thread 0 a l’affinité 0001, le 1 a l’affinité 0010, le 2 a l’affinité 0100, le 3 l’affinité 1000, et le 4 l’affinité 0001 etc…
- le boost dynamique est désactivé  pour que ça ne perturbe pas les résultats.

Dans les faits il y a 4 CPU sur la machine, non HT (un vieux core i5 2320). Ci-dessous les résultats des temps d’exécution de chaque sommation pour 1,2,3 et 4 threads concurrents:

E:\CAPDATA\DEV\CPP\contextswitchdemo\x64\Debug>contextswitchdemo.exe 1
Thread id (0) created with priority 1, affinity mask : 1.000000
Thread id (0) Done in 7286 ms!
1 threads, all sums done in 7286 ms

E:\CAPDATA\DEV\CPP\contextswitchdemo\x64\Debug>contextswitchdemo.exe 2
Thread id (0) created with priority 1, affinity mask : 1.000000
Thread id (1) created with priority 1, affinity mask : 2.000000
Thread id (0) Done in 7379 ms!
Thread id (1) Done in 7457 ms!
2 threads, all sums done in 7457 ms

E:\CAPDATA\DEV\CPP\contextswitchdemo\x64\Debug>contextswitchdemo.exe 3
Thread id (0) created with priority 1, affinity mask : 1.000000
Thread id (1) created with priority 1, affinity mask : 2.000000
Thread id (2) created with priority 1, affinity mask : 4.000000
Thread id (1) Done in 7488 ms!
Thread id (0) Done in 7550 ms!
Thread id (2) Done in 7628 ms!
3 threads, all sums done in 7628 ms

E:\CAPDATA\DEV\CPP\contextswitchdemo\x64\Debug>contextswitchdemo.exe 4
Thread id (0) created with priority 1, affinity mask : 1.000000
Thread id (1) created with priority 1, affinity mask : 2.000000
Thread id (2) created with priority 1, affinity mask : 4.000000
Thread id (3) created with priority 1, affinity mask : 8.000000
Thread id (0) Done in 6989 ms!
Thread id (2) Done in 7176 ms!
Thread id (1) Done in 7238 ms!
Thread id (3) Done in 7301 ms!
4 threads, all sums done in 7301 ms

Entre 7.2s et 7.6s, les temps restent constants, mais qu’est-ce qui se passe si je rajoute un thread supplémentaire:

E:\CAPDATA\DEV\CPP\contextswitchdemo\x64\Debug>contextswitchdemo.exe 5
Thread id (0) created with priority 1, affinity mask : 1.000000
Thread id (2) created with priority 1, affinity mask : 4.000000
Thread id (3) created with priority 1, affinity mask : 8.000000
Thread id (1) created with priority 1, affinity mask : 2.000000
Thread id (4) created with priority 1, affinity mask : 1.000000
Thread id (2) Done in 7223 ms!
Thread id (1) Done in 7254 ms!
Thread id (3) Done in 7286 ms!
Thread id (0) Done in 14306 ms!
Thread id (4) Done in 14415 ms!
5 threads, all sums done in 14415 ms

Trois threads conservent leur temps de référence, en revanche le 4 et le 0 mettent précisément deux fois plus de temps. Utilisons le Concurrency Visualizer de Visual Studio  pour y voir plus clair:

Lors de l’exécution à 4 threads, on voit que le temps d’exécution est optimisé: chaque thread  a son core dédié sur lequel l’affinité a été établie, et seul le thread père passe son temps en synchronisation (en rose) sur WaitForMultipleObjects, les autres passent leur temps en exécution. Les stries sur la partie verte indiquent qu’il y a cependant des changements de contexte en raison du dépassement régulier du quantum. Les histogrammes en dessous montrent cependant que le temps passé sur ces changements de contexte est infinitésimal par rapport au temps passé en exécution, chaque thread étant reschédulé lui-même sur son core affilié:

 

Lors de l’exécution à 5 threads, on retrouve nos 3 threads à 7 secondes, et le 0 et le 4 qui ont la même affinité (0001) s’échangent leur CPU à tour de rôle, en passant d’un état running à un état preempted.

 

Alors attention à l’utilisation du terme Preempted dans le contexte du Concurrency Visualizer. Ça ne veut pas dire qu’il y a un thread de plus forte priorité, mais simplement un dépassement de quantum au profit d’un autre thread, c’est écrit dans la doc et je trouve que la formulation est assez mal choisie. Bref ce n’est pas une vraie préemption, tous les threads évoluant au même niveau de priorité, simplement une façon d’indiquer que le temps CPU est partagé à 50-50 entre le thread 0 et le thread 4. D’ailleurs lorsque l’on sélectionne une zone jaune sur la piste du thread 0 ou 4, la stack nous indique qu’il s’agit bien d’un dépassement de quantum:

 

 

 

 

 

 

 

 

Si on continue d’augmenter le nombre de threads concurrents, on continue d’augmenter le temps d’exécution des différents threads car ils sont de plus en plus nombreux à vouloir accéder à la CPU. Pour 8 threads par exemple, ils se retrouvent presque tous avec un temps d’exécution logiquement multiplié par deux (14s) puisqu’il n’y a plus que 1 CPU pour 2 threads:

 

Et le temps d’exécution moyen augmente avec le nombre de threads en concurrence:

 

 

 

 

 

 

 

 

 

 

 

 

 

Quel rapport avec SQL Server ?

On est bien d’accord que jusqu’ici, on ne parle que du point de vue de Windows. Pourtant tout au début, sys.dm_os_schedulers nous renvoie vers ces mêmes notions (préemption, context switch), alors quel est le lien entre un context switch windows et un context switch SQL Server ?

Réponse et c’est l’objectif de cet article: aucun lien. Si la définition du changement de contexte est la même dans les deux cas, ce sont bien deux événements distincts qui n’interviennent pas du tout au même niveau.

Parce que SQL Server dispose de son propre système d’exploitation (SQLOS), il utilise les mêmes fonctionnalités, et notamment dans le domaine de la planification : des workers (= des threads), des quantums, des CPUs logiques (les schedulers de sys.dm_os_schedulers). Simplement il remodèle les concepts en rapport avec ses besoins :

- Il sait que ses workers ont besoin de plus de temps pour travailler. Le temps accordé par windows seul n’est pas suffisant et il lui faut un quantième de temps plus long pour aller au bout de son batch

- Il sait mieux que l’OS à quel endroit et quel moment il peut se permettre de relâcher volontairement la CPU pour un autre worker.

- Et surtout, la décision de partager le temps CPU n’est plus la volonté arbitraire d’un seul composant comme c’est le cas sur Windows. C’est à la charge de chaque petit worker. D’où la notion de scheduler coopératif sur SQL Server vs scheduler préemptif sur Windows.

Tout un système coopératif ne signifie pas pour autant permettre à un worker de garder la main trois heures sur un scheduler. Il faut donc trouver un compromis entre pure coopération où chaque worker passe autant de temps qu’il en a besoin sur un scheduler quitte à le monopoliser, et la notion d’équité que l’on retrouve dans Windows. D’où les notions de quantum et de changements de contexte au niveau de SQLOS. (En référence le vieux Inside the User Mode Scheduler de Ken Henderson, toujours un classique)

Quand à la notion de préemption, elle n’a pas du tout le même sens, ce n’est pas une histoire de priorité mais de laisser le thread s’exécuter en dehors du contexte de SQLOS, lorsque celui-ci va exécuter des actions non contrôlables par exemple une procédure stockée étendue.

Un exemple classique : un worker effectue un long range scan en mémoire. Il ne bloque sur aucune ressource (verrou, latch, IO), il a une autoroute ouverte devant lui mais plusieurs centaines de mégaoctets à parcourir quand même. Pour éviter qu’il ne monopolise le scheduler, il dispose d’un bail de 4ms par défaut, au-delà duquel il s’interrompra spontanément (coopératif) pour céder sa place au prochain thread runnable. Il va comptabiliser une attente sur SOS_SCHEDULER_YIELD, puis va se replacer tout seul au fond de la runnable list.

Lorsqu’il cède sa place, il y a un changement de contexte au niveau de son scheduler, qui viendra incrémenter le compteur que l’on a vu en début de ce post. On comprend bien ici que tout se passe en usermode et que ce changement de contexte se fait bien au niveau de SQL Server et pas au niveau de Windows. Ci-dessous un exemple de gros range scan d’index qui tient en mémoire:

select ProductID, transactionDate from bigTransactionHistory
where productID between 1000 and 24000;

/*
|--Index Seek(OBJECT:([SCE3].[dbo].[bigTransactionHistory].[IX_ProductId_TransactionDate]), SEEK:([SCE3].[dbo].[bigTransactionHistory].[ProductID] >= CONVERT_IMPLICIT(int,[@1],0) AND [SCE3].[dbo].[bigTransactionHistory].[ProductID]

(12697128 row(s) affected)
Table 'bigTransactionHistory'. Scan count 1, logical reads 53539, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

(1 row(s) affected)
*/

 

Et le décompte des context switches par seconde côté SQLOS avec une DMV et côté windows via perfmon (Threads\Context Switches/sec):

declare @ctx1 bigint, @ctx2 bigint;
set @ctx1=0;
set @ctx2=0;

if (object_id('tempdb.dbo.#capture') is not null)
	drop table #capture ;
create table #capture (curdate datetime, ctxsw bigint) ;

while (1=1)
BEGIN
	select @ctx1 = W.context_switch_count  
	from sys.dm_os_workers W
	join sys.dm_os_tasks TA on TA.task_address = W.task_address
	where TA.session_id = 54 ;
	waitfor delay '00:00:01'
	select @ctx2 = W.context_switch_count  
	from sys.dm_os_workers W
	join sys.dm_os_tasks TA on TA.task_address = W.task_address
	where TA.session_id = 54 ;

	insert into #capture (curdate,ctxsw) select getutcdate(), (@ctx2-@ctx1) ;
END;

select * from #capture order by curdate asc ;

 

 

 

 

 

 

 

 

 

 

 

 
Pour le même thread, les valeurs de changement de contexte SQLOS et Windows ne sont pas comparables. Le worker est context-switché par SQLOS, et le thread sur lequel il s’appuie est context switché par Windows, ce sont deux évènements distincts et les décisions de context switcher sont prises indépendamment l’une de l’autre.

Conclusion :

Nous avons vu que la notion de changement de contexte est utilisée à la fois par le dispatcher Windows et dans SQL Server par SQLOS, pour des raisons d’équité dans les deux cas, mais à des niveaux très différents.

Donc on affirmer sans détours que : Context switch Windows != context switch SQLOS.

A+

Continuez votre lecture sur le blog :




Cliquer pour partager cet article sur Viadeo
Cliquer sur "CAPTURER" pour sauvegarder cet article dans Evernote Clip to Evernote

Tags: ,

Leave a Reply