web-dev-qa-db-fra.com

Comprendre le multitraitement: gestion de la mémoire partagée, verrous et files d'attente dans Python

Multiprocessing est un outil puissant en python, et je veux le comprendre plus en profondeur. Je veux savoir quand utiliser régulierLocks et Queues et quand utiliser un multiprocessing Manager pour partager ceux-ci parmi tous les processus.

J'ai proposé les scénarios de test suivants avec quatre conditions différentes pour le multitraitement:

  1. Utilisation d'un pool et [~ # ~] non [~ # ~] Manager

  2. Utilisation d'un pool et d'un gestionnaire

  3. Utilisation de processus individuels et [~ # ~] non [~ # ~] Manager

  4. Utilisation de processus individuels et d'un gestionnaire

Le travail

Toutes les conditions exécutent une fonction de travail the_job. the_job Consiste en une impression sécurisée par un verrou. De plus, l'entrée de la fonction est simplement mise dans une file d'attente (pour voir si elle peut être récupérée de la file d'attente). Cette entrée est simplement un index idx de range(10) créé dans le script principal appelé start_scenario (Affiché en bas).

def the_job(args):
    """The job for multiprocessing.

    Prints some stuff secured by a lock and 
    finally puts the input into a queue.

    """
    idx = args[0]
    lock = args[1]
    queue=args[2]

    lock.acquire()
    print 'I'
    print 'was '
    print 'here '
    print '!!!!'
    print '1111'
    print 'einhundertelfzigelf\n'
    who= ' By run %d \n' % idx
    print who
    lock.release()

    queue.put(idx)

Le succès d'une condition est défini comme rappelant parfaitement l'entrée de la file d'attente, voir la fonction read_queue En bas.

Les conditions

Les conditions 1 et 2 sont assez explicites. La condition 1 implique la création d'un verrou et d'une file d'attente, et leur transmission à un pool de processus:

def scenario_1_pool_no_manager(jobfunc, args, ncores):
    """Runs a pool of processes WITHOUT a Manager for the lock and queue.

    FAILS!

    """
    mypool = mp.Pool(ncores)
    lock = mp.Lock()
    queue = mp.Queue()

    iterator = make_iterator(args, lock, queue)

    mypool.imap(jobfunc, iterator)

    mypool.close()
    mypool.join()

    return read_queue(queue)

(La fonction d'aide make_iterator Est donnée au bas de cet article.) La condition 1 échoue avec RuntimeError: Lock objects should only be shared between processes through inheritance.

La condition 2 est assez similaire mais maintenant le verrou et la file d'attente sont sous la supervision d'un gestionnaire:

def scenario_2_pool_manager(jobfunc, args, ncores):
    """Runs a pool of processes WITH a Manager for the lock and queue.

    SUCCESSFUL!

    """
    mypool = mp.Pool(ncores)
    lock = mp.Manager().Lock()
    queue = mp.Manager().Queue()

    iterator = make_iterator(args, lock, queue)
    mypool.imap(jobfunc, iterator)
    mypool.close()
    mypool.join()

    return read_queue(queue)

Dans la condition 3, de nouveaux processus sont démarrés manuellement et le verrou et la file d'attente sont créés sans gestionnaire:

def scenario_3_single_processes_no_manager(jobfunc, args, ncores):
    """Runs an individual process for every task WITHOUT a Manager,

    SUCCESSFUL!

    """
    lock = mp.Lock()
    queue = mp.Queue()

    iterator = make_iterator(args, lock, queue)

    do_job_single_processes(jobfunc, iterator, ncores)

    return read_queue(queue)

La condition 4 est similaire mais utilise à nouveau un gestionnaire:

def scenario_4_single_processes_manager(jobfunc, args, ncores):
    """Runs an individual process for every task WITH a Manager,

    SUCCESSFUL!

    """
    lock = mp.Manager().Lock()
    queue = mp.Manager().Queue()

    iterator = make_iterator(args, lock, queue)

    do_job_single_processes(jobfunc, iterator, ncores)

    return read_queue(queue)

Dans les deux conditions - 3 et 4 - je lance un nouveau processus pour chacune des 10 tâches de the_job Avec au plus ncores processus fonctionnant en même temps. Ceci est réalisé avec la fonction d'assistance suivante:

def do_job_single_processes(jobfunc, iterator, ncores):
    """Runs a job function by starting individual processes for every task.

    At most `ncores` processes operate at the same time

    :param jobfunc: Job to do

    :param iterator:

        Iterator over different parameter settings,
        contains a lock and a queue

    :param ncores:

        Number of processes operating at the same time

    """
    keep_running=True
    process_dict = {} # Dict containing all subprocees

    while len(process_dict)>0 or keep_running:

        terminated_procs_pids = []
        # First check if some processes did finish their job
        for pid, proc in process_dict.iteritems():

            # Remember the terminated processes
            if not proc.is_alive():
                terminated_procs_pids.append(pid)

        # And delete these from the process dict
        for terminated_proc in terminated_procs_pids:
            process_dict.pop(terminated_proc)

        # If we have less active processes than ncores and there is still
        # a job to do, add another process
        if len(process_dict) < ncores and keep_running:
            try:
                task = iterator.next()
                proc = mp.Process(target=jobfunc,
                                                   args=(task,))
                proc.start()
                process_dict[proc.pid]=proc
            except StopIteration:
                # All tasks have been started
                keep_running=False

        time.sleep(0.1)

Le résultat

Seule la condition 1 échoue (RuntimeError: Lock objects should only be shared between processes through inheritance) Alors que les 3 autres conditions sont réussies. J'essaie de comprendre ce résultat.

Pourquoi le pool doit-il partager un verrou et une file d'attente entre tous les processus, mais pas les processus individuels de la condition 3?

Ce que je sais, c'est que pour les conditions de pool (1 et 2), toutes les données des itérateurs sont transmises via le décapage, tandis que dans les conditions de processus unique (3 et 4), toutes les données des itérateurs sont transmises par héritage du processus principal (je suis en utilisant Linux ). Je suppose que jusqu'à ce que la mémoire soit modifiée à partir d'un processus enfant, la même mémoire que le processus parental utilise est accessible (copie sur écriture). Mais dès que l'on dit lock.acquire(), cela devrait être changé et les processus enfants utilisent des verrous différents placés ailleurs en mémoire, n'est-ce pas? Comment un processus enfant sait-il qu'un frère a activé un verrou qui n'est pas partagé via un gestionnaire?

Enfin, ma question est quelque peu liée à la différence des conditions 3 et 4. Les deux ont des processus individuels, mais ils diffèrent dans l'utilisation d'un gestionnaire. Les deux sont-ils considérés comme du code valide? Ou faut-il éviter d'utiliser un gestionnaire s'il n'en a pas réellement besoin?


Script complet

Pour ceux qui veulent simplement copier et coller tout pour exécuter le code, voici le script complet:

__author__ = 'Me and myself'

import multiprocessing as mp
import time

def the_job(args):
    """The job for multiprocessing.

    Prints some stuff secured by a lock and 
    finally puts the input into a queue.

    """
    idx = args[0]
    lock = args[1]
    queue=args[2]

    lock.acquire()
    print 'I'
    print 'was '
    print 'here '
    print '!!!!'
    print '1111'
    print 'einhundertelfzigelf\n'
    who= ' By run %d \n' % idx
    print who
    lock.release()

    queue.put(idx)


def read_queue(queue):
    """Turns a qeue into a normal python list."""
    results = []
    while not queue.empty():
        result = queue.get()
        results.append(result)
    return results


def make_iterator(args, lock, queue):
    """Makes an iterator over args and passes the lock an queue to each element."""
    return ((arg, lock, queue) for arg in args)


def start_scenario(scenario_number = 1):
    """Starts one of four multiprocessing scenarios.

    :param scenario_number: Index of scenario, 1 to 4

    """
    args = range(10)
    ncores = 3
    if scenario_number==1:
        result =  scenario_1_pool_no_manager(the_job, args, ncores)

    Elif scenario_number==2:
        result =  scenario_2_pool_manager(the_job, args, ncores)

    Elif scenario_number==3:
        result =  scenario_3_single_processes_no_manager(the_job, args, ncores)

    Elif scenario_number==4:
        result =  scenario_4_single_processes_manager(the_job, args, ncores)

    if result != args:
        print 'Scenario %d fails: %s != %s' % (scenario_number, args, result)
    else:
        print 'Scenario %d successful!' % scenario_number


def scenario_1_pool_no_manager(jobfunc, args, ncores):
    """Runs a pool of processes WITHOUT a Manager for the lock and queue.

    FAILS!

    """
    mypool = mp.Pool(ncores)
    lock = mp.Lock()
    queue = mp.Queue()

    iterator = make_iterator(args, lock, queue)

    mypool.map(jobfunc, iterator)

    mypool.close()
    mypool.join()

    return read_queue(queue)


def scenario_2_pool_manager(jobfunc, args, ncores):
    """Runs a pool of processes WITH a Manager for the lock and queue.

    SUCCESSFUL!

    """
    mypool = mp.Pool(ncores)
    lock = mp.Manager().Lock()
    queue = mp.Manager().Queue()

    iterator = make_iterator(args, lock, queue)
    mypool.map(jobfunc, iterator)
    mypool.close()
    mypool.join()

    return read_queue(queue)


def scenario_3_single_processes_no_manager(jobfunc, args, ncores):
    """Runs an individual process for every task WITHOUT a Manager,

    SUCCESSFUL!

    """
    lock = mp.Lock()
    queue = mp.Queue()

    iterator = make_iterator(args, lock, queue)

    do_job_single_processes(jobfunc, iterator, ncores)

    return read_queue(queue)


def scenario_4_single_processes_manager(jobfunc, args, ncores):
    """Runs an individual process for every task WITH a Manager,

    SUCCESSFUL!

    """
    lock = mp.Manager().Lock()
    queue = mp.Manager().Queue()

    iterator = make_iterator(args, lock, queue)

    do_job_single_processes(jobfunc, iterator, ncores)

    return read_queue(queue)


def do_job_single_processes(jobfunc, iterator, ncores):
    """Runs a job function by starting individual processes for every task.

    At most `ncores` processes operate at the same time

    :param jobfunc: Job to do

    :param iterator:

        Iterator over different parameter settings,
        contains a lock and a queue

    :param ncores:

        Number of processes operating at the same time

    """
    keep_running=True
    process_dict = {} # Dict containing all subprocees

    while len(process_dict)>0 or keep_running:

        terminated_procs_pids = []
        # First check if some processes did finish their job
        for pid, proc in process_dict.iteritems():

            # Remember the terminated processes
            if not proc.is_alive():
                terminated_procs_pids.append(pid)

        # And delete these from the process dict
        for terminated_proc in terminated_procs_pids:
            process_dict.pop(terminated_proc)

        # If we have less active processes than ncores and there is still
        # a job to do, add another process
        if len(process_dict) < ncores and keep_running:
            try:
                task = iterator.next()
                proc = mp.Process(target=jobfunc,
                                                   args=(task,))
                proc.start()
                process_dict[proc.pid]=proc
            except StopIteration:
                # All tasks have been started
                keep_running=False

        time.sleep(0.1)


def main():
    """Runs 1 out of 4 different multiprocessing scenarios"""
    start_scenario(1)


if __name__ == '__main__':
    main()
38
SmCaterpillar

multiprocessing.Lock Est implémenté à l'aide d'un objet Semaphore fourni par le système d'exploitation. Sous Linux, l'enfant hérite simplement d'un descripteur du sémaphore du parent via os.fork. Ce n'est pas une copie du sémaphore; il hérite en fait du même handle que le parent, de la même manière que les descripteurs de fichiers peuvent être hérités. Windows d'autre part, ne prend pas en charge os.fork, Il doit donc décaper le Lock. Pour ce faire, il crée un descripteur en double du sémaphore Windows utilisé en interne par l'objet multiprocessing.Lock, À l'aide de l'API Windows DuplicateHandle , qui indique:

La poignée en double fait référence au même objet que la poignée d'origine. Par conséquent, toutes les modifications apportées à l'objet sont reflétées par les deux poignées

L'API DuplicateHandle vous permet de donner la propriété du handle dupliqué au processus enfant, afin que le processus enfant puisse réellement l'utiliser après l'avoir décroché. En créant une poignée dupliquée appartenant à l'enfant, vous pouvez effectivement "partager" l'objet verrou.

Voici l'objet sémaphore dans multiprocessing/synchronize.py

class SemLock(object):

    def __init__(self, kind, value, maxvalue):
        sl = self._semlock = _multiprocessing.SemLock(kind, value, maxvalue)
        debug('created semlock with handle %s' % sl.handle)
        self._make_methods()

        if sys.platform != 'win32':
            def _after_fork(obj):
                obj._semlock._after_fork()
            register_after_fork(self, _after_fork)

    def _make_methods(self):
        self.acquire = self._semlock.acquire
        self.release = self._semlock.release
        self.__enter__ = self._semlock.__enter__
        self.__exit__ = self._semlock.__exit__

    def __getstate__(self):  # This is called when you try to pickle the `Lock`.
        assert_spawning(self)
        sl = self._semlock
        return (Popen.duplicate_for_child(sl.handle), sl.kind, sl.maxvalue)

    def __setstate__(self, state): # This is called when unpickling a `Lock`
        self._semlock = _multiprocessing.SemLock._rebuild(*state)
        debug('recreated blocker with handle %r' % state[0])
        self._make_methods()

Notez l'appel assert_spawning Dans __getstate__, Qui est appelé lors du décapage de l'objet. Voici comment cela est mis en œuvre:

#
# Check that the current thread is spawning a child process
#

def assert_spawning(self):
    if not Popen.thread_is_spawning():
        raise RuntimeError(
            '%s objects should only be shared between processes'
            ' through inheritance' % type(self).__name__
            )

Cette fonction est celle qui garantit que vous "héritez" du Lock, en appelant thread_is_spawning. Sous Linux, cette méthode renvoie simplement False:

@staticmethod
def thread_is_spawning():
    return False

C'est parce que Linux n'a pas besoin de décaper pour hériter Lock, donc si __getstate__ Est réellement appelé sur Linux, nous ne devons pas hériter. Sous Windows, il se passe plus:

def dump(obj, file, protocol=None):
    ForkingPickler(file, protocol).dump(obj)

class Popen(object):
    '''
    Start a subprocess to run the code of a process object
    '''
    _tls = thread._local()

    def __init__(self, process_obj):
        ...
        # send information to child
        prep_data = get_preparation_data(process_obj._name)
        to_child = os.fdopen(wfd, 'wb')
        Popen._tls.process_handle = int(hp)
        try:
            dump(prep_data, to_child, HIGHEST_PROTOCOL)
            dump(process_obj, to_child, HIGHEST_PROTOCOL)
        finally:
            del Popen._tls.process_handle
            to_child.close()


    @staticmethod
    def thread_is_spawning():
        return getattr(Popen._tls, 'process_handle', None) is not None

Ici, thread_is_spawning Renvoie True si l'objet Popen._tls A un attribut process_handle. Nous pouvons voir que l'attribut process_handle Est créé dans __init__, Puis les données dont nous voulons hériter sont transmises du parent à l'enfant en utilisant dump, puis l'attribut est supprimé. Ainsi, thread_is_spawning Ne sera True que pendant __init__. Selon ce fil de liste de diffusion python-ideas , il s'agit en fait d'une limitation artificielle ajoutée pour simuler le même comportement que os.fork Sous Linux. Windows pourrait prendre en charge le passage de Lock à tout moment, car DuplicateHandle peut être exécuté à tout moment.

Tout ce qui précède s'applique à l'objet Queue car il utilise Lock en interne.

Je dirais que l'héritage des objets Lock est préférable à l'utilisation d'une Manager.Lock(), car lorsque vous utilisez un Manager.Lock, Chaque appel que vous effectuez vers le Lock doit être envoyé via IPC au processus Manager, ce qui sera beaucoup plus lent que d'utiliser un Lock partagé qui vit à l'intérieur du processus appelant. Les deux approches sont parfaitement valides, cependant.

Enfin, il est possible de passer un Lock à tous les membres d'un Pool sans utiliser un Manager, en utilisant le initializer/initargs arguments de mots clés:

lock = None
def initialize_lock(l):
   global lock
   lock = l

def scenario_1_pool_no_manager(jobfunc, args, ncores):
    """Runs a pool of processes WITHOUT a Manager for the lock and queue.

    """
    lock = mp.Lock()
    mypool = mp.Pool(ncores, initializer=initialize_lock, initargs=(lock,))
    queue = mp.Queue()

    iterator = make_iterator(args, queue)

    mypool.imap(jobfunc, iterator) # Don't pass lock. It has to be used as a global in the child. (This means `jobfunc` would need to be re-written slightly.

    mypool.close()
    mypool.join()

return read_queue(queue)

Cela fonctionne car les arguments passés à initargs sont passés à la méthode __init__ Des objets Process qui s'exécutent à l'intérieur de Pool, donc ils finissent par être hérités, plutôt que marinés.

32
dano