Identifier les problèmes de concurrence

Commençons pas le commencement, si vous lisez cet article ce n’est probablement pas pour y trouver la recette de la blanquette de veau, ni comment monter votre nouvelle étagère scandinave…
Vous voulez en savoir plus sur les problèmes de concurrence, et nous allons voir comment les mettre en évidence.

Les problèmes liés à la concurrence de threads peuvent être particulièrement difficiles à identifier, surtout durant la phase de développement où, généralement, les tests sont faits dans un contexte mono-utilisateur.

Bien que les problèmes posés par l’accès concurrent de plusieurs threads à une variable puissent être faciles à contourner lors de la création d’une application initialement multi-utilisateurs, il peut arriver, lors de la montée en charge d’une application qui n’était pas initialement prévue pour fonctionner à l’aide de plusieurs threads, qu’une partie du code soit amenée à être appelée par plusieurs threads en même temps.

Dès lors se pose le problème de la mise en évidence de ces actions concurrentes.

La plupart du temps, si ce genre d’erreur vous a échappé pendant la phase de développement, vous retrouverez le souci en production avec un bug apparaissant de manière aléatoire lors de l’exécution d’une action particulière.

Dans cet article nous partirons d’un souci que nous avons rencontré en production et qui affectait le quota d’utilisation de nos espaces de stockage.

Le problème que nous rencontrions était le suivant : certains de nos espaces de stockage affichaient un forte utilisation alors que ceux-ci étaient pratiquement vides ou ils affichaient une utilisation faible alors qu’un grand nombre de fichiers volumineux y étaient stockés. Cependant, la majorité avaient un quota d’utilisation cohérent au regard de ce qui y était stocké.

Le quota d’utilisation n’étant pas modifié lors de la consultation ou du téléchargement d’un fichier, nos recherches se sont intéressées aux actions d’ajout et de suppression.

Après un bon nombre de tests, il est apparu que le quota n’était pas correctement mis à jour lors de la suppression ou l’ajout de plusieurs fichiers à la fois dans l’espace de stockage. A partir de ce moment, il devient assez évident qu’un problème de concurrence est à l’origine du bug.

Nous avons donc placé des logs dans les méthodes d’ajout et de suppression de fichiers et avons ajouté/supprimé des fichiers par lot jusqu’à mettre en évidence le comportement fautif.

En effet, lors de la modification problématique de fichiers, les logs apparaissaient de la manière suivante :

13:34:30,395 DEBUG --- Start adding file : test.zip
13:34:30,407 DEBUG --- Start adding file : testing.zip
13:34:30,413 DEBUG --- Finish adding file : test.zip
13:34:30,422 DEBUG --- Finish adding file : testing.zip

Il apparaît clairement que les deux ajouts de fichiers sont exécutés simultanément.

L’ajout ou la suppression de fichier n’étant pas impacté par la concurrence nous avons identifié la méthode fautive :

public void alterWorkspaceSize( String wspUid, Long size )
{
    IWorkspace wsp = this.getWorkspace( wspUid );
    wsp.addInWorkspaceSize( size );
    this.save( wsp );
}

Cette méthode est appelée par les méthodes d’ajout et de suppression de fichier et se charge de mettre à jour le quota. Cependant elle n’est pas thread safe et cause des résultats incohérents si elle est appelée par deux threads différents au même moment.

Une fois le problème identifié, la mise en place d’un test unitaire adapté est de mise.
Pour ce faire nous avons étendu la classe TestRunnable de groboutils et créé la classe suivante :

public class WorkspaceSizeTestRunnable extends TestRunnable
{
    private final long fileSize;
    private final String wspUid;
    private final IWorkspaceService workspaceService;

    public WorkspaceSizeTestRunnable( IWorkspaceService workspaceService, String wspUid, long fileSize )
    {
        super();
        this.fileSize = fileSize;
        this.wspUid = wspUid;
        this.workspaceService = workspaceService;
    }

    @Override
    public void runTest() throws Throwable
    {
        this.workspaceService.alterWorkspaceSize( this.wspUid, new Long( this.fileSize ) );
    }

    public Long getWspSize()
    {
        return this.workspaceService.getWorkspace( this.wspUid ).getWorkspaceSize();
    }
}

Une fois cette classe implémentée, nous avons créé notre cas de test qui va lancer de nombreuses modifications de quota en même temps afin de prouver le problème de concurrence et par la suite le corriger.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = { "classpath:fileshare-kernel.junit.xml" })
public class TestQuotaConcurrency
{
    @Qualifier("fileShareWspService")
    @Autowired
    private WorkspaceService    wspService;

    private static int                TESTS_LOOPS_NUMBER    = 10;

    @Test
    public void testQuotaConcurrency() throws Throwable
    {
        String wspUid = this.prepareDatabase();
        for ( int i = 0 ; i < TestQuotaConcurrency.TESTS_LOOPS_NUMBER ; i++ )
        {
            List<WorkspaceSizeTestRunnable> testsList = new ArrayList<WorkspaceSizeTestRunnable>();
            WorkspaceSizeTestRunnable testRun1 = new WorkspaceSizeTestRunnable( this.wspService, wspUid, 10000000L );
            WorkspaceSizeTestRunnable testRun2 = new WorkspaceSizeTestRunnable( this.wspService, wspUid, -2000000L );
            testsList.add( testRun1 );
            testsList.add( testRun2 );

            MultiThreadedTestRunner mttr = new MultiThreadedTestRunner( testsList.toArray( new WorkspaceSizeTestRunnable[0] ) );
            mttr.runTestRunnables( 4000 );

            Assert.isTrue( ( testRun1.getWspSize().longValue() == 8000000L * ( i + 1 ) ) && ( testRun2.getWspSize().longValue() == 8000000L * ( i + 1 ) ), "Failed at test " + i );
        }
    }

    private String prepareDatabase()
    {
        IWorkspace wsp = this.wspService.getOrCreatePersonalWorkspace( "userTestWspSize@alinto.int" );
        wsp.setWorkspaceSize( new Long( 0L ) );
        this.wspService.save( wsp );
        return wsp.getUid();
    }
}

Dans ce test nous créons deux threads, l’un ajoute 10.000.000 Bytes au quota,l’autre lui enlève 2.000.000 Bytes. Ces deux threads sont exécutés en même temps et si tout ce déroule correctement le quota devrait être égal à 8.000.000 Bytes.

Cette opération est, par précaution, répétée 10 fois au cas où le problème de concurrence ne se présente pas à cause du ralentissement d’un des threads. De cette manière il est pratiquement certain que le souci d’écriture se présentera.

Sans modifications de la méthode incriminée, le test ne passe pas, les différents threads lisent et écrivent simultanément la nouvelle taille de l’espace de stockage et la taille finale ne correspond pas a la valeur attendue.

Le test mis en place, la méthode posant problème est maintenant clairement identifiée et testée. La correction du problème s’avère être particulièrement simple, l’ajout du keyword synchronized à la méthode alterWorkspaceSize (String wspUid, Long size) permet de rendre la méthode thread safe : en effet seul un thread peut exécuter la méthode à la fois et les autres doivent attendre que le thread précédent sorte de la méthode pour pouvoir l’exécuter.

Comme vous pouvez le voir, dans le cas de problèmes causés par la concurrence de threads, la correction est souvent simple mais l’identification de la méthode ou de l’objet posant problème n’est pas pas forcement une chose aisée. Cela dit, dans la majorité des cas les logs peuvent vous aider donc n’hésitez pas et mettez-en plein, il sera toujours possible de baisser la visibilité de ceux-ci une fois en production voire de les supprimer si jamais besoin est.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *