Копирование одного файла на несколько удаленных хостов параллельно через SFTP

Я хочу использовать Python для копирования локального файла до нескольких удаленных хостов параллельно. Я пытаюсь сделать это с asyncio и Paramiko, так как я уже использую эти библиотеки для других целей в своей программе.

Я использую BaseEventLoop.run_in_executor() по умолчанию ThreadPoolExecutor, который фактически является новым интерфейсом к старому threading библиотека, а также функция SFTP Paramiko для копирования.

вот упрощенный пример как.

import sys
import asyncio
import paramiko
import functools


def copy_file_node(
        *,
        user: str,
        host: str,
        identity_file: str,
        local_path: str,
        remote_path: str):
    ssh_client = paramiko.client.SSHClient()
    ssh_client.load_system_host_keys()
    ssh_client.set_missing_host_key_policy(paramiko.client.AutoAddPolicy())

    ssh_client.connect(
        username=user,
        hostname=host,
        key_filename=identity_file,
        timeout=3)

    with ssh_client:
        with ssh_client.open_sftp() as sftp:
            print("[{h}] Copying file...".format(h=host))
            sftp.put(localpath=local_path, remotepath=remote_path)
            print("[{h}] Copy complete.".format(h=host))


loop = asyncio.get_event_loop()

tasks = []

# NOTE: You'll have to update the values being passed in to
#      `functools.partial(copy_file_node, ...)`
#       to get this working on on your machine.
for host in ['10.0.0.1', '10.0.0.2']:
    task = loop.run_in_executor(
        None,
        functools.partial(
            copy_file_node,
            user='user',
            host=host,
            identity_file='/path/to/identity_file',
            local_path='/path/to/local/file',
            remote_path='/path/to/remote/file'))
    tasks.append(task)

try:
    loop.run_until_complete(asyncio.gather(*tasks))
except Exception as e:
    print("At least one node raised an error:", e, file=sys.stderr)
    sys.exit(1)

loop.close()

проблема, которую я вижу, заключается в том, что файл копируется последовательно на хосты, а не параллельно. Поэтому, если копирование занимает 5 секунд для одного хоста, оно занимает 10 секунд для двух хостов и так далее.

Я пробовал различные другие подходы, включая канаву SFTP и трубопроводы файла в dd на каждом из удаленных хостов через exec_command(), но копии всегда происходит последовательно.

Я, вероятно, неправильно понимаю некоторые основные идея здесь. Что мешает различным потокам копировать файл параллельно?

из моего тестирования кажется, что задержка происходит на удаленной записи, а не при чтении локального файла. Но почему это так, поскольку мы пытаемся выполнить сетевой ввод-вывод против независимых удаленных хостов?

2 ответов


нет ничего плохого в использовании ввода-вывода.

чтобы доказать это, давайте попробуем упрощенную версию вашего скрипта-нет парамико, просто чистый Python.

import asyncio, functools, sys, time

START_TIME = time.monotonic()

def log(msg):
    print('{:>7.3f} {}'.format(time.monotonic() - START_TIME, msg))

def dummy(thread_id):
    log('Thread {} started'.format(thread_id))
    time.sleep(1)
    log('Thread {} finished'.format(thread_id))

loop = asyncio.get_event_loop()
tasks = []
for i in range(0, int(sys.argv[1])):
    task = loop.run_in_executor(None, functools.partial(dummy, thread_id=i))
    tasks.append(task)
loop.run_until_complete(asyncio.gather(*tasks))
loop.close()

С двумя потоками, это будет печать:

$ python3 async.py 2
  0.001 Thread 0 started
  0.002 Thread 1 started       <-- 2 tasks are executed concurrently
  1.003 Thread 0 finished
  1.003 Thread 1 finished      <-- Total time is 1 second

этот параллелизм масштабируется до 5 потоков:

$ python3 async.py 5
  0.001 Thread 0 started
  ...
  0.003 Thread 4 started       <-- 5 tasks are executed concurrently
  1.002 Thread 0 finished
  ...
  1.005 Thread 4 finished      <-- Total time is still 1 second

если мы добавим еще один поток, мы достигнем предела пула потоков:

$ python3 async.py 6
  0.001 Thread 0 started
  0.001 Thread 1 started
  0.002 Thread 2 started
  0.003 Thread 3 started
  0.003 Thread 4 started       <-- 5 tasks are executed concurrently
  1.002 Thread 0 finished
  1.003 Thread 5 started       <-- 6th task is executed after 1 second
  1.003 Thread 1 finished
  1.004 Thread 2 finished
  1.004 Thread 3 finished
  1.004 Thread 4 finished      <-- 5 task are completed after 1 second
  2.005 Thread 5 finished      <-- 6th task is completed after 2 seconds

все идет как ожидалось, и общее время растет по 1 секунде на каждые 5 предметов. Магическое число 5 задокументировано в ThreadPoolExecutor docs:

изменено в версии 3.5: If max_workers is None или не задано, по умолчанию будет указано количество процессоров на машине, умноженное на 5, предполагая, что ThreadPoolExecutor часто используется для перекрытия ввода-вывода вместо работы процессора, и количество работников должно быть выше, чем количество работников для ProcessPoolExecutor.

как сторонняя библиотека может заблокировать мой ThreadPoolExecutor?

  • библиотека использует какой-то глобальный замок. Это означает, что библиотека не поддерживает многопоточность. Попробуйте использовать ProcessPoolExecutor, но с осторожностью: библиотека может содержать другие анти-шаблоны, такие как использование того же жесткого временного имени файла.

  • функция выполняется в течение длительного времени и не освобождает GIL. Это может укажите ошибку в коде расширения C, но самой популярной причиной проведения GIL является выполнение некоторых вычислений с интенсивным процессором. Опять же, вы можете попробовать ProcessPoolExecutor, так как он не зависит от GIL.

ничего из этого не должно произойти с библиотекой, такой как paramiko.

как сторонняя библиотека может заблокировать мой ProcessPoolExecutor?

обычно не может. Ваши задачи выполняются в отдельных процессах. Если вы видите, что две задачи ProcessPoolExecutor занимает в два раза больше времени, подозревая узкое место ресурса (например, потребляя 100% пропускной способности сети).


Я не уверен, что это лучший способ подойти к нему, но это работает для меня

#start
from multiprocessing import Process

#omitted

tasks = []
for host in hosts:
    p = Process(
        None,
        functools.partial(
          copy_file_node,
          user=user,
          host=host,
          identity_file=identity_file,
          local_path=local_path,
          remote_path=remote_path))

    tasks.append(p)

[t.start() for t in tasks]
[t.join() for t in tasks]

на основе комментария, добавил метку данных и захватил вывод из многопроцессорной обработки и получил следующее:

2015-10-24 03:06:08.749683[vagrant1] Copying file...
2015-10-24 03:06:08.751826[basement] Copying file...
2015-10-24 03:06:08.757040[upstairs] Copying file...
2015-10-24 03:06:16.222416[vagrant1] Copy complete.
2015-10-24 03:06:18.094373[upstairs] Copy complete.
2015-10-24 03:06:22.478711[basement] Copy complete.