Loupe

[C++ / PPL] tasks c++ et temps de vie des objets

Une des grosses différences entre du code natif C++ et du code managé (C#, VB…), est le fait que de base, la durée de vie des objets n’est pas contrôlée par le runtime (le Garbage Collector .Net), mais est de la responsabilité du développeur. Cela a un impact assez important lorsque l’on écrit du code asynchrone, et que l’on veut dans une callback manipuler des variables membre.

En effet si l’on observe le code C# suivant :

public class MyClassWithAsyncThings
{
    private int _memberVariable;
    public void DoSomethingWithTasks()
    {
        OtherComponent.DoSomethingAsync()
            .ContinueWith(previousTask =>
                {
                    _memberVariable = 42;
                });
    }
}

l’accès correct à la variable membre est garantie par la CLR. En effet de manière implicite, la lambda passée à ContinueWith capture la référence à “this” et la CLR le sait : temps que ma lambda est référencée ou est en cours d’exécution, le Garbage Collector ne supprimera pas l’instance de “MyClassWithAsyncThings” référencée.

Si l’on prend maintenant le code C++ quasi-équivalent suivant:

class MyClassWithAsyncThings
{
private:
    int _memberVariable;
public:
    void DoSomethingWithTasks()
    {
        doSomethingAsync().then([this](){
            _memberVariable = 42;
        });
    }
}

la capture du pointeur “this” ne garantie pas que l’objet n’a pas été détruit entre le moment où “DoSomethingWithTasks” a été invoqué et celui où la callback a été exécutée. Si l’objet est détruit avant que la lambda ne s’exécute on se retrouve dans une situation de “undefined behavior” conduisant potentiellement au mieux à un crash du programme, au pire à des trucs complètement imprévisibles et absolument horribles que la décence ne me permet pas de décrire ici.

Pour se prémunir contre cela, il faut donc trouver un moyen de :

  • détecter que le destructeur de l’objet a été invoqué et annuler l’exécution de la tâche le cas échéant
  • empêcher l’exécution du destructeur pendant que l’on accède aux variables membres

Le 1er point est réglable de manière assez simple, en utilisant les cancellation_token et cancellation_token_source :

class MyClassWithAsyncThings
{
private:
    int _memberVariable;
    cancellation_token_source _dctorCts;
public:
    ~MyClassWithAsyncThings(){
        _dctorCts.cancel();
    }
    void DoSomethingWithTasks()
    {
        auto cancelToken = _dctorCts.get_token();
        doSomethingAsync().then([this, cancelToken](){
            if(cancelToken.is_canceled()){
                cancel_current_task();
            }
            _memberVariable = 42;
        }, cancelToken);
    }
}

Pour le 2e, cela est un tout petit peu plus compliqué : il va nous falloir un mutex, mais qui ne soit pas stocké directement comme membre de la classe (sinon on ne pourra pas le verrouiller après que l’objet soit détruit), mais plutôt encapsulé dans un shared_ptr (pour qu’il ne soit détruit que lorsque tous les objets susceptibles de le verrouiller soient détruits):

class MyClassWithAsyncThings
{
private:
    int _memberVariable;
    std::shared_ptr<std::mutex> _pMutex;
    cancellation_token_source _dctorCts;
public:
    MyClassWithAsyncThings(): _pMutex(new std::mutex()){

    }
    ~MyClassWithAsyncThings(){
        // acquire mutex, then cancel
        std::lock_guard<std::mutex> lg(*_pMutex);
        _dctorCts.cancel();
    }
    void DoSomethingWithTasks()
    {
        std::shared_ptr<std::mutex> localMutexPtr(_pMutex);
        auto cancelToken = _dctorCts.get_token();
        doSomethingAsync().then([this, cancelToken, localMutexPtr](){
            // acquire mutex, then check for cancellation
            std::lock_guard<std::mutex> lg(*localMutexPtr);
            if(cancelToken.is_canceled()){
                cancel_current_task();
            }
            _memberVariable = 42;
        }, cancelToken);
    }
};

Ce pattern est absolument nécessaire pour garantir l’accès sécurisé aux variables membres, et malheureusement, il est plutôt compliqué de l’implémenter correctement (une erreur commune est de déclarer le mutex comme variable membre, ou d’oublier de l’instancier dynamiquement dans le constructeur, ou d’oublier de le recopier dans une variable locale pour le capturer avant l’exécution asynchrone). Afin d’éviter de réinventer la roue à chaque fois que j’écris ce genre de code, j’ai du coup écrit une classe encapsulant le mutex et le cancellation_token_source, et exposant des méthodes simples à appeler aux moment critiques. Ce composant est dispo sur github dans la lib ppltasksex : https://github.com/simonferquel/ppltasksex

Le code précédant pourra du coup s’écrire de la manière suivante :

class MyClassWithAsyncThings
{
private:
    int _memberVariable;
    ppltasksex::lifetime_checker _lifetimeChecker;
public:
    ~MyClassWithAsyncThings(){
        // acquire mutex, then cancel
        _lifetimeChecker.onDestructing();
    }
    void DoSomethingWithTasks()
    {
        ppltasksex::lifetime_checker localChecker(_lifetimeChecker);
        doSomethingAsync().then([this, localChecker](){
            // acquire mutex, then check for cancellation
            localChecker.acquireOrCancel();
            _memberVariable = 42;
        }, localChecker.getCancelToken());
    }
};

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus