Як ми забили на асинхронність при походах на бэкенды

threads

Під натиском появи нових асинхронних неблокирующихся фреймворків може здатися, що блокирующиеся виклики — це пережиток минулого, і все нові сервіси потрібно писати повністю асинхронної архітектурі. У цьому пості я розповім, як ми вирішили відмовитися від неблокирующих асинхронних викликів бэкендов на користь звичайних блокуючих.

В архітектурі HeadHunter є сервіс, який збирає дані з інших сервісів. Наприклад, щоб показати вакансії по пошуковому запиту, потрібно:
  1. сходити в бекенд пошуку за «айдишками» вакансій;
  2. сходити в бекенд вакансій за їх описом.
Це найпростіший приклад. Часто в цьому сервісі багато всякої логіки. Ми його навіть назвали «logic».

Спочатку він був написаний на python. За кілька років існування logic в ньому накопичилося всякого тех. боргу. Та й розробники були не в захваті від необхідності копатися в python, так і в java, на якій у нас написано більшість бэкендов. І ми подумали, чому б не переписати logic на java.

Причому python logic у нас прогресивний, побудований на асинхронному неблокирующемся фреймворку tornado. Питання «блокуватися або не блокуватися при поході на бэкенды» навіть не стояло: GIL в python немає справжнього паралельного виконання потоків, тому хочеш — не хочеш, а запити треба обробляти в одному потоці і не блокуватися при походах в інші сервіси.

А ось при переході на java ми вирішили ще раз оцінити, чи хочемо продовжувати писати вивернутий коллбэчный код.
def search_vacancies(query):
def on_vacancies_ids_received(vacancies_ids):
get_vacancies(vacancies_ids, callback=reply_to_client)
search_vacancies_ids(query, callback=on_vacancies_ids_received)

Звичайно callback hell можна згладити. В java 8, наприклад, з'явилася CompletableFuture. Ще можна подивитися в бік Akka, Vert.x, Quasar і т. д. Але, може бути, нам не потрібні нові рівні абстракції, і ми можемо повернутися до звичайних синхронним блокується викликам?
def search_vacancies(query):
vacancies_ids = search_vacancies_ids(query)
return get_vacancies(vacancies_ids)

В цьому випадку ми будемо виділяти під обробку кожного запиту потік, який при поході на бекенд буде блокуватися до тих пір, поки не отримає результат, а потім продовжить виконання. Зверніть увагу, що я кажу про блокування потоку в момент виклику віддаленого сервісу. Вичитування запиту і запис результату в сокет буде здійснюватися без блокування. Тобто, потік буде виділятися під готовий запит, а не під з'єднання. Чим потенційно погана блокування потоку?
  1. Потрібно багато пам'яті, так як кожному потоку потрібна пам'ять під стек.
  2. Все буде гальмувати, так як перемикання між контекстами потоків — не безкоштовна операція.
  3. Якщо бэкенды затупят, то вільних потоків в пулі не залишиться.
Ми вирішили порахувати, скільки нам знадобиться потоків, а потім оцінити, зауважимо ми ці проблеми.

Скільки потрібно потоків?
Нижню межу оцінити нескладно.
Припустимо, зараз у python logic такі логи:
15:04:00 400 ms GET /vacancies
15:04:00 600 ms GET /resumes
15:04:01 500 ms GET /vacancies
15:04:01 600 ms GET /resumes

Друга колонка — це час від надходження запиту до віддачі відповіді. Тобто logic обробив:
15:04:00 сумарна тривалість запитів - 1000 ms
15:04:01 сумарна тривалість запитів - 1100 ms

Якщо ми будемо виділяти під обробку кожного запиту потік, то:
  • у 15:04:00 ми теоретично можемо обійтися одним потоком, який спочатку обробить запит GET /vacancies, а потім обробить запит GET /resumes;
  • а ось в 15:04:01 вже доведеться виділяти мінімум 2 потоку, так як один потік за одну секунду ніяк не зможе обробити більше секунди запитів.
Насправді, саме навантажене час на python logic така сумарна тривалість запитів:

python logic requests sec / wall sec

Більше 150 секунд запитів за секунду. Тобто нам потрібно більше 150 потоків. Запам'ятаємо це число. Але треба ще якось врахувати, що запити надходять нерівномірно, потік може бути повернутий в пул не відразу після обробки запиту, а трохи пізніше, і т. д.

Давайте візьмемо інший сервіс, який блокується при походах в базу даних, подивимося, скільки йому потрібно потоків, і екстраполюємо числа. Ось, наприклад, сервіс запрошень та відгуків:

negotiations requests sec / wall sec

До 14 секунд запитів за секунду. А що з фактичним використанням потоків?

negotiations busy threads

До 54-х одночасно використовуваних потоків, що в 2-4 рази більше в порівнянні з теоретично мінімальною кількістю. Ми дивилися на інші сервіси — там схожа картина.

Тут доречно зробити невеличкий відступ. У HeadHunter як http сервера використовується jetty, але в інших серверах http схожа архітектура:
  • кожен запит — це завдання;
  • ця задача поступає в чергу перед пулом потоків;
  • якщо в пулі є вільний потік — він бере завдання з черги і виконує її;
  • якщо вільного потоку немає — завдання лежить в черзі, поки що вільний потік не з'явиться.
Якщо нам не страшно, що запит буде деякий час лежати в черзі можна виділити потоків трохи більше, ніж розрахований мінімум. Але якщо ми хочемо максимально скоротити затримки, то потоків потрібно виділити більше.

Давайте виділимо в 4 рази більше потоків.
Тобто, якщо ми зараз переведемо весь python logic на java logic з блокирующейся архітектурою, то нам буде потрібно 150 * 4 = 600 потоків.
Давайте уявимо, що навантаження зросте в 2 рази. Тоді, якщо ми не упремося в CPU, нам потрібно 1200 потоків.
Ще уявімо, що наші бэкенды туплять, і на обслуговування запитів йде в 2 рази більше часу, але про це пізніше, поки що нехай буде 2400 потоків.
Зараз python logic крутиться на чотирьох серверах, тобто на кожному буде 2400 / 4 = 600 потоків.
600 потоків — це багато чи мало?

Кілька сотень тредів — це багато чи мало?
За замовчуванням, на 64-х бітних машинах java виділяє під стек потоку 1 МБ пам'яті.
Тобто для 600 потоків потрібно 600 МБ пам'яті. Не катастрофа. До того ж це — 600 МБ віртуального адресного простору. Фізична оперативна пам'ять буде задіяна тільки тоді, коли ця пам'ять дійсно потрібно. Нам майже ніколи не потрібно 1 МБ стека, ми часто затискаємо його до 512 КБ. У цьому сенсі ні 600, ні навіть 1000 потоків для нас не проблема.

Що з витратами на перемикання контексту між потоками?
Ось простенький тест на java:
  • створюємо пул потоків розміром 1, 2, 4, 8… 4096;
  • закидаємо в нього 16 384 завдання;
  • кожна задача — це 600 000 ітерацій складання випадкових чисел;
  • чекаємо виконання всіх завдань;
  • запускаємо тест 2 рази для прогріву;
  • запускаємо тест ще 5 разів і беремо середнє час.
static final int numOfWarmUps = 2;
static final int numOfTests = 5;
static final int numOfTasks = 16_384;
static final int numOfIterationsPerTask = 600_000;

public static void main(String[] args) throws Exception {
for (int numOfThreads : new int[] {1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096}) {
System.out.println(numOfThreads + " threads.");
ExecutorService executorService = Executors.newFixedThreadPool(numOfThreads);

System.out.println("Warming up...");
for (int i=0; i < numOfWarmUps; i++) {
test(executorService);
}

System.out.println("Testing...");
for (int i = 0; i < numOfTests; i++) {
long start = currentTimeMillis();
test(executorService);
System.out.println(currentTimeMillis() - start);
}

executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.SECONDS);
System.out.println();
}
}

static void test(ExecutorService executorService) throws Exception {
List<Future<Integer>> resultsFutures = new ArrayList<>(numOfTasks);
for (int i = 0; i < numOfTasks; i++) {
resultsFutures.add(executorService.submit(new Task()));
}
for (Future<Integer> resultFuture : resultsFutures) {
resultFuture.get();
}
}

static class Task implements Callable<Integer> {
private final Random random = new Random();
@Override
public Integer call() throws InterruptedException {
int sum = 0;
for (int i = 0; i < numOfIterationsPerTask; i++) {
sum += random.nextInt();
}
return sum;
}
}

Ось результати на 4-х ядерний i7-3820, HyperThreading відключений, Ubuntu Linux 64-bit. Очікуємо, що кращий результат покаже пул з чотирма потоками (за кількістю ядер), так що порівнюємо інші результати з них:
Кількість потоків Середній час, мс Стандартне відхилення Різниця, %
1 109152 9,6 287,70%
2 55072 35,6 95,61%
4 28153 3,8 0,00%
8 28142 2,8 -0,04%
16 28141 3,6 -0,04%
32 28152 3,7 0,00%
64 28149 6,6 -0,01%
128 28146 2,3 -0,02%
256 28146 4,1 -0,03%
512 28148 2,7 -0,02%
1024 28146 2,8 -0,03%
2048 28157 5,0 0,01%
4096 28160 3,0 0,02%
Різниця між 4 і 4096 потоками порівнянна з похибкою. Так що і в сенсі накладних витрат від перемикання контекстів 600 потоків для нас не є проблемою.

А якщо бэкенды затупят?
Уявімо, що у нас затупил один з бэкендов, і тепер запити до нього займають в 2, 4, 10 разів більше часу. Це може призвести до того, що всі потоки будуть висіти заблокованими, і ми не можемо обробляти інші запити, яким цей бекенд не потрібен. В цьому випадку ми можемо зробити кілька речей.

По-перше, залишити про запас ще більше потоків.
По-друге, виставити жорсткі таймаут. За таймаутами треба стежити, це може бути проблемою. Чи варта вона того, щоб писати асинхронний код? Питання відкрите.
По-третє, ніхто не змушує нас писати все в синхронному стилі. Наприклад, якісь контролери ми цілком можемо написати в асинхронному стилі, якщо очікуємо проблем з бэкендами.

Таким чином, для сервісу походів по бэкендам при наших навантаженнях ми зробили вибір на користь переважно блокирующейся архітектури. Так, нам потрібно стежити за таймаутами, і ми не можемо швидко отмасштабироваться в 100 разів. Але, з іншого боку, ми можемо писати простий і зрозумілий код, швидше випускати бізнес-фічі і витрачати менше часу на підтримку, що, на мій погляд, теж дуже круто.

Корисні посилання



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.