Многопоточность в Python: повышение производительности приложений

Пройдите тест, узнайте какой профессии подходите
Сколько вам лет
0%
До 18
От 18 до 24
От 25 до 34
От 35 до 44
От 45 до 49
От 50 до 54
Больше 55

Для кого эта статья:

  • Python-разработчики, желающие углубить свои знания в области многопоточности и параллельного программирования
  • Студенты и специалисты, интересующиеся оптимизацией производительности программного обеспечения
  • Инженеры и разработчики, работающие над высоконагруженными системами и серверными приложениями

    Медлительные скрипты, операции ввода-вывода, тормозящие весь процесс, вечно загруженный CPU при обработке данных — знакомые проблемы? Многопоточность в Python — мощный инструмент, позволяющий разбить эти оковы и заставить ваш код работать на полную мощность. Независимо от того, обрабатываете ли вы массивные датасеты или создаете отзывчивый веб-сервер — понимание тонкостей управления потоками может стать решающим фактором между посредственным и выдающимся кодом. Погружаемся в мир параллельного выполнения, где каждая миллисекунда на счету! 🚀

Стремитесь стать экспертом в области многопоточности? Обучение Python-разработке от Skypro включает углубленный модуль по параллельному программированию. В отличие от поверхностных туториалов, здесь вы получите практические навыки работы с GIL, threading, asyncio и профессиональную отладку многопоточных приложений. Наши выпускники создают высоконагруженные системы, которые эффективно используют все ресурсы сервера.

Основы многопоточности в Python и архитектура GIL

Многопоточность в Python позволяет выполнять несколько операций параллельно в рамках одного процесса. Это особенно полезно для задач, ограниченных операциями ввода-вывода (I/O-bound tasks) — сетевыми запросами, файловыми операциями и ожиданием внешних ресурсов. Однако, прежде чем погрузиться в практическое применение, необходимо понять фундаментальное ограничение Python — Global Interpreter Lock (GIL).

GIL — это мьютекс (блокировка), позволяющий только одному потоку выполнять байт-код Python в рамках одного процесса интерпретатора. Это означает, что независимо от количества ядер процессора и созданных потоков, в каждый момент времени только один поток может выполнять Python-код.

Александр Петров, Lead Python Developer

Однажды мне пришлось оптимизировать систему обработки финансовых транзакций, обрабатывающую более миллиона запросов ежедневно. Сервис работал медленно, особенно в часы пик, когда количество пользователей возрастало. Первым шагом я внедрил многопоточность для параллельной обработки HTTP-запросов, но производительность улучшилась незначительно.

Анализ показал, что узким местом был именно GIL — потоки блокировали друг друга при выполнении CPU-интенсивных операций по валидации и шифрованию данных. Решением стало разделение приложения на несколько процессов с использованием модуля multiprocessing для вычислительно сложных задач, при сохранении threading для I/O-операций. Такой гибридный подход позволил увеличить пропускную способность системы в 8 раз и сократить время отклика с 2.5 до 0.3 секунды.

Когда GIL действительно становится проблемой? Давайте рассмотрим два основных типа задач:

Тип задачи Описание Влияние GIL Рекомендуемый подход
I/O-bound Задачи, ограниченные операциями ввода-вывода (сеть, файлы) Минимальное, так как GIL освобождается во время ожидания I/O Threading или asyncio
CPU-bound Вычислительно интенсивные задачи (обработка данных, математические вычисления) Значительное — потоки конкурируют за GIL Multiprocessing

Почему же GIL существует, несмотря на такие ограничения? Основные причины:

  • Упрощение управления памятью и сборки мусора
  • Обеспечение потокобезопасности для C-расширений
  • Историческое наследие (внедрен в 1990-х)

Важно понимать, что GIL не делает многопоточность в Python бесполезной. Для I/O-bound задач многопоточность по-прежнему дает значительный прирост производительности, так как во время ожидания ввода-вывода GIL освобождается, позволяя другим потокам выполнять работу.

Теперь, когда мы понимаем архитектурные ограничения, давайте рассмотрим практические инструменты для работы с потоками в Python. 🔧

Пошаговый план для смены профессии

Практическое применение модуля threading в Python

Модуль threading — основной инструмент для работы с потоками в стандартной библиотеке Python. Он предоставляет высокоуровневый интерфейс, позволяющий создавать и управлять потоками, а также обеспечивает различные средства синхронизации.

Начнем с базового примера создания и запуска потока:

Python
Скопировать код
import threading
import time

def worker(name):
print(f"Worker {name} started")
time.sleep(2) # Имитация работы
print(f"Worker {name} finished")

# Создаем потоки
thread1 = threading.Thread(target=worker, args=("A",))
thread2 = threading.Thread(target=worker, args=("B",))

# Запускаем потоки
thread1.start()
thread2.start()

# Ожидаем завершения
thread1.join()
thread2.join()

print("All workers completed")

В этом примере мы создаем два потока, которые выполняют функцию worker(), и затем ожидаем их завершения с помощью метода join(). Важно отметить, что метод start() только инициирует выполнение потока, но не блокирует основной поток, в то время как join() блокирует выполнение до завершения потока.

Для более сложных сценариев часто используется подход с наследованием от класса Thread:

Python
Скопировать код
class WorkerThread(threading.Thread):
def __init__(self, name, delay):
super().__init__()
self.name = name
self.delay = delay

def run(self):
print(f"Thread {self.name} starting")
time.sleep(self.delay)
print(f"Thread {self.name} finished after {self.delay}s")

# Создаем и запускаем потоки
threads = [
WorkerThread("One", 2),
WorkerThread("Two", 4),
WorkerThread("Three", 1)
]

for thread in threads:
thread.start()

for thread in threads:
thread.join()

При работе с потоками критически важно правильно синхронизировать доступ к общим ресурсам, чтобы избежать состояний гонки (race conditions). Модуль threading предлагает несколько механизмов синхронизации:

  • Lock — простая блокировка для исключения одновременного доступа
  • RLock — повторно входимая блокировка, позволяющая одному потоку получить блокировку несколько раз
  • Semaphore — счетчик для ограничения числа одновременно выполняемых потоков
  • Event — сигнал для коммуникации между потоками
  • Condition — комплексный механизм для координации потоков

Рассмотрим пример использования Lock для защиты общего ресурса:

Python
Скопировать код
counter = 0
counter_lock = threading.Lock()

def increment_counter(amount, repeats):
global counter
for _ in range(repeats):
with counter_lock: # Эквивалент try/finally с lock.acquire() и lock.release()
counter += amount

# Запускаем 10 потоков, каждый увеличивает счетчик 100000 раз
threads = []
for i in range(10):
t = threading.Thread(target=increment_counter, args=(1, 100000))
threads.append(t)
t.start()

for t in threads:
t.join()

print(f"Final counter value: {counter}") # Должно быть 1000000

Без использования Lock, значение счетчика, вероятно, будет меньше ожидаемого из-за состояний гонки, когда несколько потоков одновременно читают и модифицируют значение.

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

Python
Скопировать код
start_event = threading.Event()

def worker(id):
print(f"Worker {id} waiting for start signal")
start_event.wait() # Блокируется до установки флага
print(f"Worker {id} started processing")

# Создаем и запускаем потоки
threads = []
for i in range(5):
t = threading.Thread(target=worker, args=(i,))
threads.append(t)
t.start()

# Имитация подготовки
time.sleep(2)
print("Main thread: All preparations done, workers can start")

# Сигнализируем всем потокам начать работу
start_event.set()

# Ожидаем завершения
for t in threads:
t.join()

При использовании многопоточности важно помнить о нескольких ключевых моментах:

  • Чрезмерное использование блокировок может привести к снижению производительности или даже взаимным блокировкам (deadlocks)
  • Потоки имеют накладные расходы на создание и переключение контекста
  • Дебаггинг многопоточных приложений может быть сложным из-за недетерминированного поведения
  • Даже с идеальной синхронизацией, GIL ограничивает параллельное выполнение вычислительных задач

Высокоуровневые инструменты: concurrent.futures

Модуль concurrent.futures, добавленный в Python 3.2, предоставляет высокоуровневый интерфейс для асинхронного выполнения задач с использованием потоков или процессов. Он значительно упрощает работу с параллельным выполнением, абстрагируясь от низкоуровневых деталей.

Основные компоненты concurrent.futures:

  • ThreadPoolExecutor — пул потоков для выполнения задач
  • ProcessPoolExecutor — пул процессов для обхода ограничений GIL
  • Future — объект, представляющий отложенный результат вычисления

Рассмотрим пример использования ThreadPoolExecutor для параллельной загрузки веб-страниц:

Python
Скопировать код
import concurrent.futures
import requests
import time

urls = [
'https://www.python.org/',
'https://docs.python.org/',
'https://pypi.org/',
'https://github.com/',
'https://stackoverflow.com/'
]

def download_url(url):
start_time = time.time()
response = requests.get(url)
end_time = time.time()
return {
'url': url,
'status': response.status_code,
'size': len(response.content),
'time': end_time – start_time
}

# Последовательное выполнение для сравнения
start = time.time()
for url in urls:
result = download_url(url)
print(f"Sequential: {result['url']} – {result['size']} bytes – {result['time']:.2f}s")
print(f"Total sequential time: {time.time() – start:.2f}s")

# Параллельное выполнение с ThreadPoolExecutor
start = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
results = executor.map(download_url, urls)

for result in results:
print(f"Parallel: {result['url']} – {result['size']} bytes – {result['time']:.2f}s")

print(f"Total parallel time: {time.time() – start:.2f}s")

Преимущество ThreadPoolExecutor в том, что он автоматически управляет созданием и повторным использованием потоков, а также предоставляет удобные методы для обработки результатов.

Для CPU-bound задач, где GIL становится узким местом, можно использовать ProcessPoolExecutor, который запускает задачи в отдельных процессах:

Python
Скопировать код
import concurrent.futures
import math
import time

def calculate_prime_factors(n):
factors = []
d = 2
while n > 1:
while n % d == 0:
factors.append(d)
n //= d
d += 1
if d*d > n:
if n > 1: 
factors.append(n)
break
return factors

numbers = [9876543210, 87654321098, 7654321098, 654321098, 54321098]

# Последовательное вычисление
start = time.time()
for num in numbers:
factors = calculate_prime_factors(num)
print(f"Factors of {num}: {factors}")
print(f"Sequential execution time: {time.time() – start:.2f}s")

# Параллельное вычисление с ProcessPoolExecutor
start = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
results = executor.map(calculate_prime_factors, numbers)

for num, factors in zip(numbers, results):
print(f"Factors of {num}: {factors}")

print(f"Parallel execution time: {time.time() – start:.2f}s")

Выбор между ThreadPoolExecutor и ProcessPoolExecutor зависит от характера задачи:

Параметр ThreadPoolExecutor ProcessPoolExecutor
Рекомендуемые задачи I/O-bound (сеть, файлы, БД) CPU-bound (вычисления, обработка данных)
Ресурсы Легковесные, используют общую память Тяжеловесные, каждый процесс имеет собственную память
Накладные расходы Низкие Высокие (создание процесса, сериализация данных)
Ограничения GIL Подвержены ограничениям GIL Обходят GIL (каждый процесс имеет собственный GIL)
Передача данных Прямой доступ к общим данным Требуется сериализация/десериализация (pickle)

Для более гибкого контроля над выполнением задач, concurrent.futures предлагает методы submit() и as_completed():

Python
Скопировать код
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
# Создаем словарь {future: url} для отслеживания результатов
future_to_url = {executor.submit(download_url, url): url for url in urls}

# Обрабатываем результаты по мере их завершения
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
data = future.result()
print(f"{url} downloaded: {data['size']} bytes")
except Exception as exc:
print(f"{url} generated an exception: {exc}")

Модуль concurrent.futures также предоставляет удобные функции для выполнения задач с таймаутом и отмены задач:

Python
Скопировать код
def long_running_task():
# Имитация долгой задачи
time.sleep(10)
return "Task completed"

with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(long_running_task)

try:
# Ожидаем результата максимум 5 секунд
result = future.result(timeout=5)
print(result)
except concurrent.futures.TimeoutError:
print("Task took too long, cancelling...")
future.cancel() # Попытка отмены задачи

Марина Соколова, DevOps-инженер

В нашем проекте мы столкнулись с узким местом при развертывании обновлений на кластере из 200+ серверов. Скрипт последовательно подключался по SSH к каждому серверу, обновлял пакеты и перезапускал службы, что занимало около 4 часов.

Я переписала систему с использованием concurrent.futures.ThreadPoolExecutor, создав пул из 30 потоков для параллельного подключения к серверам. Для надежности добавила обработку исключений, повторные попытки и таймауты:

Python
Скопировать код
def update_server(server):
retries = 3
for attempt in range(retries):
try:
# Подключение по SSH и обновление
client = paramiko.SSHClient()
client.connect(server, timeout=30)
# Выполнение команд обновления
return {"server": server, "status": "success"}
except Exception as e:
if attempt == retries – 1:
return {"server": server, "status": "failed", "error": str(e)}
time.sleep(5) # Пауза перед повторной попыткой

with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
futures = {executor.submit(update_server, server): server for server in servers}

for future in concurrent.futures.as_completed(futures):
result = future.result()
if result["status"] == "success":
print(f"✅ {result['server']} updated successfully")
else:
print(f"❌ {result['server']} failed: {result['error']}")

Время развертывания сократилось с 4 часов до 15 минут, и мы получили четкую картину статуса каждого сервера в реальном времени.

Асинхронное программирование vs многопоточность

В Python существует два основных подхода к параллельному выполнению операций ввода-вывода: многопоточность (threading) и асинхронное программирование (asyncio). Хотя они решают схожие задачи, механизмы их работы и применения существенно различаются.

Асинхронное программирование в Python реализовано через модуль asyncio, который предоставляет инфраструктуру для написания однопоточного конкурентного кода с использованием синтаксиса async/await. Ключевое отличие от многопоточности заключается в том, что асинхронный код выполняется в одном потоке, явно передавая управление между задачами.

Основные отличия asyncio от threading:

  • Asyncio использует кооперативную многозадачность, где задачи сами отдают управление, в отличие от вытесняющей многозадачности в threading
  • Асинхронный код выполняется в одном потоке, что устраняет необходимость в сложных механизмах синхронизации
  • Для эффективной работы с asyncio требуются библиотеки, поддерживающие асинхронные операции (aiohttp вместо requests, asyncpg вместо psycopg2 и т.д.)
  • Код с asyncio часто более производительный для I/O-bound задач, но требует специфического дизайна

Рассмотрим примеры решения одной задачи с использованием обоих подходов. Вот как выглядит параллельная загрузка URL с помощью threading:

Python
Скопировать код
import threading
import requests
import time

def download_url(url):
response = requests.get(url)
print(f"Downloaded {url}, size: {len(response.content)} bytes")

urls = [
'https://www.python.org/',
'https://docs.python.org/',
'https://pypi.org/',
'https://github.com/',
'https://stackoverflow.com/'
]

start = time.time()
threads = []

for url in urls:
thread = threading.Thread(target=download_url, args=(url,))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

print(f"Downloaded {len(urls)} URLs in {time.time() – start:.2f} seconds")

А вот аналогичная задача с использованием asyncio:

Python
Скопировать код
import asyncio
import aiohttp
import time

async def download_url(url, session):
async with session.get(url) as response:
content = await response.read()
print(f"Downloaded {url}, size: {len(content)} bytes")

async def main():
urls = [
'https://www.python.org/',
'https://docs.python.org/',
'https://pypi.org/',
'https://github.com/',
'https://stackoverflow.com/'
]

async with aiohttp.ClientSession() as session:
tasks = [download_url(url, session) for url in urls]
await asyncio.gather(*tasks)

start = time.time()
asyncio.run(main())
print(f"Downloaded {len(urls)} URLs in {time.time() – start:.2f} seconds")

Когда выбирать asyncio, а когда threading? Вот краткое сравнение:

Критерий Threading Asyncio
Простота использования Проще для новичков, знакомый императивный стиль Требует понимания асинхронных концепций и использования async/await
Масштабируемость Ограничена, потоки потребляют ресурсы ОС Высокая, тысячи задач в одном потоке
Совместимость с библиотеками Работает с любыми библиотеками Требует асинхронных аналогов библиотек
Блокирующие операции Блокирующие операции останавливают только текущий поток Блокирующие операции останавливают весь event loop
Отладка Сложнее из-за непредсказуемого переключения потоков Проще благодаря детерминированной природе
CPU-bound задачи Ограничены GIL, но можно комбинировать с multiprocessing Не подходит, блокирует весь event loop

Интересно, что оба подхода можно комбинировать для достижения оптимальной производительности:

Python
Скопировать код
import asyncio
import concurrent.futures
import requests
import time
import aiohttp

# Функция для потоков (обычные I/O операции)
def download_with_requests(url):
response = requests.get(url)
return f"Threading: {url}, size: {len(response.content)}"

# Асинхронная функция для asyncio
async def download_with_aiohttp(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
content = await response.read()
return f"Asyncio: {url}, size: {len(content)}"

# Гибридная функция, запускающая threading внутри asyncio
async def main():
urls = [
'https://www.python.org/',
'https://docs.python.org/',
'https://pypi.org/',
'https://github.com/',
'https://stackoverflow.com/'
]

# Асинхронные задачи
asyncio_tasks = [download_with_aiohttp(url) for url in urls]

# Задачи в потоках
loop = asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor() as pool:
threading_tasks = [
loop.run_in_executor(pool, download_with_requests, url)
for url in urls
]

# Ожидаем выполнения всех задач
results = await asyncio.gather(
*asyncio_tasks,
*threading_tasks
)

return results

start = time.time()
results = asyncio.run(main())
for result in results:
print(result)
print(f"Completed all tasks in {time.time() – start:.2f} seconds")

Такой гибридный подход особенно полезен, когда требуется интегрировать существующие блокирующие библиотеки в асинхронное приложение.

Следует отметить, что с Python 3.9+ появилась возможность использовать асинхронные контекстные менеджеры и итераторы в более удобной форме, что делает asyncio ещё более привлекательным выбором для новых проектов. 🔄

Оптимизация производительности и отладка потоков

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

Основные проблемы производительности и отладки в многопоточных приложениях:

  • Race conditions — когда несколько потоков одновременно изменяют общие данные
  • Deadlocks — когда два или более потоков взаимно блокируют друг друга
  • Starvation — когда некоторые потоки не получают достаточно ресурсов
  • Чрезмерное переключение контекста — когда создается слишком много потоков
  • GIL contention — когда потоки конкурируют за GIL

Рассмотрим основные методы оптимизации многопоточных приложений в Python:

  1. Правильное определение размера пула потоков

Оптимальный размер пула потоков зависит от характера задачи. Для I/O-bound задач хорошей практикой является формула:

Python
Скопировать код
import os
import multiprocessing

# Для I/O-bound задач
num_threads = min(32, multiprocessing.cpu_count() * 4) # 4x число ядер, но не больше 32

# Для смешанных задач (с элементами CPU-work)
num_threads = min(16, multiprocessing.cpu_count() * 2) # 2x число ядер, но не больше 16

  1. Минимизация блокировок и использование thread-local данных

Блокировки необходимы для защиты общих ресурсов, но чрезмерное их использование может привести к снижению производительности. Вместо частого использования глобальных блокировок, лучше использовать thread-local хранилища для данных, специфичных для каждого потока:

Python
Скопировать код
import threading

# Создаем thread-local хранилище
thread_local = threading.local()

def process_request(request_data):
# Инициализируем сессию для потока, если ее еще нет
if not hasattr(thread_local, 'session'):
thread_local.session = create_session() # Функция создания сессии

# Используем сессию, специфичную для потока
response = thread_local.session.process(request_data)
return response

  1. Профилирование и мониторинг многопоточного кода

Для обнаружения узких мест в многопоточных приложениях полезно использовать специализированные инструменты профилирования:

Python
Скопировать код
import cProfile
import pstats
import threading
import io

def profiled_function():
profiler = cProfile.Profile()
profiler.enable()

# Выполнение кода
result = your_threaded_function()

profiler.disable()
s = io.StringIO()
ps = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
ps.print_stats()
print(s.getvalue())

return result

Для мониторинга работающих потоков можно использовать стандартные инструменты Python:

Python
Скопировать код
import threading
import time

def monitor_threads():
while True:
active_threads = threading.enumerate()
print(f"Active threads: {len(active_threads)}")
for thread in active_threads:
print(f" – {thread.name}, daemon: {thread.daemon}, alive: {thread.is_alive()}")
time.sleep(5) # Обновление каждые 5 секунд

# Запускаем мониторинг в отдельном потоке
monitor_thread = threading.Thread(target=monitor_threads, name="ThreadMonitor", daemon=True)
monitor_thread.start()

  1. Отладка многопоточных приложений

Отладка многопоточных приложений может быть сложной из-за недетерминированного поведения. Полезные практики включают:

  • Использование логирования с указанием идентификатора потока
  • Детерминистические тесты с контролируемым переключением потоков
  • Инструменты для обнаружения гонок данных и взаимных блокировок

Пример улучшенного логирования для многопоточного приложения:

Python
Скопировать код
import logging
import threading
import time

# Настройка логгера
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(threadName)s] [%(levelname)s] – %(message)s',
handlers=[
logging.FileHandler("threads.log"),
logging.StreamHandler()
]
)

def worker(name):
logging.info(f"Worker {name} started")
try:
# Выполнение работы
time.sleep(2)
if name == 'B':
raise ValueError("Sample error in thread B")
logging.info(f"Worker {name} finished successfully")
except Exception as e:
logging.error(f"Error in worker {name}: {e}", exc_info=True)

# Запускаем потоки
threads = []
for name in ['A', 'B', 'C']:
thread = threading.Thread(target=worker, args=(name,), name=f"Thread-{name}")
thread.start()
threads.append(thread)

for thread in threads:
thread.join()

logging.info("All workers completed")

  1. Использование более эффективных структур данных

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

  • queue.Queue — потокобезопасная очередь для коммуникации между потоками
  • collections.deque — потокобезопасная двусторонняя очередь для некоторых операций
  • concurrent.futures.ThreadPoolExecutor — высокоуровневый интерфейс для управления пулом потоков

Для повышения производительности критических секций можно использовать атомарные операции из модуля atomicwrites или нативные расширения на C.

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

Python
Скопировать код
import threading
import time
import statistics
import concurrent.futures

def benchmark(func, *args, num_threads=10, iterations=1000):
times = []

def worker():
start = time.time()
func(*args)
end = time.time()
times.append(end – start)

with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(worker) for _ in range(iterations)]
concurrent.futures.wait(futures)

return {
'min': min(times),
'max': max(times),
'avg': sum(times) / len(times),
'median': statistics.median(times),
'stdev': statistics.stdev(times) if len(times) > 1 else 0
}

# Пример использования
results = benchmark(some_threaded_function, arg1, arg2, num_threads=20, iterations=1000)
print(f"Performance results: {results}")

Параллельная обработка данных с многопоточностью — не просто добавка к арсеналу Python-разработчика, а необходимый инструмент для создания высокопроизводительных систем. От понимания тонкостей GIL до мастерского владения concurrent.futures и asyncio — путь, который превращает посредственный код в профессиональный.

Помните: правильно выбранный подход к параллелизму в 10 раз эффективнее, чем самые изощренные алгоритмические оптимизации. Начните с правильного определения типа задачи (I/O-bound или CPU-bound), затем выберите соответствующий инструмент. Это фундаментальное решение определит успех вашего многопоточного приложения.

Загрузка...