3. Декомпозиция
● Функциональная декомпозиция
● Декомпозиция по данным
10/21/12
4. Декомпозиция по данным
Original:
for (int i = 0; i < 10; ++i)
foo(i);
Thread 1: Thread 2:
for (int i = 0; i < 5; ++i) for (int i = 5; i < 10; ++i)
foo(i); foo(i);
● Пример: рабочие потоки, “разгребающие” очередь задач
● Хорошо масштабируется с ростом CPU.
10/21/12
5. Функциональная декомпозиция
Original:
foo();
bar();
Thread 1: Thread 2:
boo(); bar();
● Пример: поток ввода, поток вывода, рабочий поток, поток
реконфигурации и пр.
● Не масштабируется.
10/21/12
6. Процессы vs Потоки
● Один и тот же task_struct, один и тот же do_fork()
● Потоки разделяют память, а процессы – нет.
PostgreSQL: процессы, shared memory для buffer pool, блокировки тоже в
shared memory
10/21/12
7. Потоки – много или мало?
● Современные планировщики ОС обрабатывают N kernel-thread'ов за
O(1) или O(log(N)) время
● Kernel-thread ~ process: тяжелое создание и ~6 страниц памяти =>
нужен пул тредов
● M потоков на один CPU => ухудшается cache hit
(поток должен завершить текущую задачу и только потом перейти к
следующей)
● Если нужен высокий параллелизм, то нужны coroutines; или events...
10/21/12
8. Hyper Threading
и когда много потоков хорошо
● Потоки делят один кэш
● Если cache hit rate <50%, то всегда выгоднее 2 и более потока на ядро
● Если у одного потока высокий hit rate, то добавление дополнительных
потоков на ядро ухудшит производительность
● На одно ядро желательно не планировать потоки, исполняющие
разный код с разными данными
10/21/12
9. Синхронизация
● pthread_mutex_lock() - только один поток владеет ресурсом
● pthread_rwlock_wrlock()/pthread_rwlock_rdlock() - один писатель или
много читателей
● pthread_cond_wait() - ожидать наступления события
● pthread_spin_lock() - проверяет блокировку в цикле
● (первые 3 работают через futex(2))
10/21/12
10. Pthreads & futex(2)
static volatile int val = 0;
void lock() {
int c;
while ((c = __sync_fetch_and_add(&val, 1))
lll_futex_wait(&val, c + 1);
}
void unlock() {
val = 0;
lll_futex_wake(&val, 1);
}
10/21/12
11. pthread_cond_broadcast:
гремящее стадо
● Атомарные операции и, тем более, системный вызов – дорогие
операции
● Поэтому лучше использовать pthread_cond_signal()
10/21/12
12. Инвалидация кэшей на мьютексе
● Если поток не может захватить мьютекс, то он уходит в сон
● => на текущем процессоре будет запущен другой поток
● => этот поток “вымоет кэш”
● Когда мьютекс будет отпущен, то поток продолжит выполнение с
“вымытым” кэшем или на другом процессоре
10/21/12
15. pthread_rwlock
● “дороже” pthread_mutex (больше сама структура данных ~ в 1.5 раза,
сложнее операция взятия лока)
● Снижает lock contention при превалировании числа читателей над
писателями, но снижается производительность per-cpu
10/21/12
16. Lock contention
● Один процесс удерживает лок, другие процессы ждут —
система становится однопроцессной
● Актуален с увеличением числа вычислительных ядер (или
потоков исполнения) и числом блокировок в программе
● Признак: ресурсы сервера используются слабо (CPU, IO
etc), но число RPS невысокий
● Методы борьбы: увеличение гранулярности блокировок,
использование более легких методов синхронизации
10/21/12
17. Lock upgrade
pthread_rwlock_rdlock(&lock);
if (v > shared) { pthread_rwlock_unlock(&lock); return; }
pthread_rwlock_unlock(&lock);
pthread_rwlock_wrlock(&lock);
if (v > shared) { pthread_rwlock_unlock(&lock); return; }
shared = v;
pthread_rwlock_unlock(&lock);
10/21/12
21. Когерентность кэшей
● Непротиворечивость кэшированных данных на многопроцессорных
системах
● Обеспечивается протоколом MESI (Modified, Exclusive, Shared, Invalid)
● RFO (Request For Ownership) := M → I
– CPU1 пишет по адресу X: X → M
– CPU2 пишет по адресу X:
– CPU1: X → I
– CPU2: X → M
10/21/12
22. Пример: std::shared_ptr
● std::shared_ptr использует reference counter – целую переменную,
разделяемую и модифицируемую всеми потоками
10/21/12
23. False sharing
● MESI оперирует cacheline'ами
● RFO довольно дорогая операция
● Если две различные переменные находятся в одном cacheline, то
возникает RFO
10/21/12
24. Выравнивание
● Компилятор автоматически выравнивает элементы структур данных
● GCC имеет специальные атрибуты, управляющие выравниванием
данных
10/21/12
25. Кэш
1. Структура для хранения и быстрого поиска данных по некоторому
ключу
● общая структура для хранения и поиска (дерево или хэш)
● две структуры: для индекса и для алгоритма вытеснения (обычно
связный список)
2. Алгоритм вытеснения данных
3. Flush-потоки или вытеснение при записи
10/21/12
26. Примеры хранилищ
● Состояния HTTP или IM сессий
● Данные о пользователях по ID или логинам
● Буфферный кэш СУБД
10/21/12
27. Цена доступа
● Основное узкое место: время обращения к памяти
● По мере роста структур, данные перестают помещаться в кэши (L1d,
L2d, L3d, TLB L1d/L2d etc)
● Выход их TLB (~1024 страницы) может стоить до 4х обращений к
памяти вместо одного
=> Основные критерии к структурам данных:
● малый объем вспомогательных данных
● пространственная локальность обращений
10/21/12
29. Структуры данных & page table
Application Tree
Page table
a.1 a b
a.0 c
c.0
c.1
10/21/12
30. Radix-tree
● Гарантированность времени доступа
● На практике использует больше всего памяти
Очень медленное: на тесте равномерного распределения IPv4 адресов
почти в 2 раза проигрывает хэшу с простой хэш-функцией времени и 4
раза по памяти
(Linux VMM выбирает ключи для сохранения пространственной
локальности)
10/21/12
31. {B,T}-tree
● Хорошая пространственная локальность
● Как правило, время поиска можно считать
константным для RAM-only структур данных
● Довольно дорогие вставки
● На практике все равно медленнее хэшей
10/21/12
32. Бинарные деревья
● В целом, слишком много обращений к памяти
● Балнсировка может быть довольно дорогой
10/21/12
33. Хэш
● Как правило, обладает лучшим средним временем доступа
● На практике использует меньше всего памяти
● Хорошая пространственная локальность: 1 случайное обращение и
линейное сканирование
● Тяжело выбрать достаточно хорошую хэш-функцию (зависит от
ключей и нагрузки)
● В некоторых случаях может обладать очень большим временем
поиска
10/21/12
34. Хэш: оптимизация (1)
● Двойное хэширование
● Определение размера на старте, как процент от доступной памяти
(без динамического рехэшинга)
● Просто повысить гранулярность блокировок
10/21/12
36. Алгоритмы вытеснения
● LRU (Least Recently Used): наиболее простой (O(1)), fundamental
locality
● Clok: алгоритм «второй попытки» без перемещений элементов в
списке (лучше concurrency)
● LRFU: модификация LFU (Least Frequently Used), но работает за
линейное время, cлишком долго “помнит” историю
● CAR (Clock with Adaptive Replacement)/CART (CAR with Temporal
filtering): объединяет fundamental и advanced locality, устойчив к
линейному сканированию
10/21/12
38. Способ вытеснения
1. Flushing-thread:
● максимальная скорость записи и чтения
● пробуждается по таймеру или при нехватке памяти
2. Во время чтения или записи:
● чтение/запись медленее
● подходит для soft-realtime
● проще алгоритм
10/21/12
39. Lock-free структуры данных
● Обычно простые структуры данных (односвязные списки,
переменные)
● Сущестуют сложные протоколы изменения деревьев и хэшей –
достаточно дорогие из-за своей сложности
● Наиболее актуальны в условиях высокого lock contention на
многопроцессорных системах
● Очень полезны в «горячих» структурах: очереди задач, аллокаторы и
пр.
10/21/12
40. Очердь задач
● Обычно 1 производитель, N потребителей
● Реализует только операции push() и pop() (нет сканирований по
списку)
● Классический (наивный) вариант: std::queue, защищенный mutex'ом
● Может быть реализована на атомарных операциях для снижения lock
contention
10/21/12
41. Атомарные операции
(Read-Modify-Write)
unsigned long a, b = 1;
b = __sync_fetch_and_add(&a, 1);
mov $0x1,%edx
lock xadd %rdx,(%rax)
10/21/12
42. Атомарные операции
(Compare-And-Swap)
unsigned long val = 5;
__sync_val_compare_and_swap(&val, 5, 2);
mov $0x5,%eax
mov $0x2,%ecx
lock cmpxchg %rcx,(%rdx)
10/21/12
43. Атомарные операции
(стоимость)
● Реализуются через протокол cache coherency (MESI)
● Писать в одну область памяти на разных процессорах дорого, т.к.
процессоры должны обмениваться сообщениями RFO
Используются в shared_ptr для reference counting
10/21/12
44. Lock-free очередь (список):
push()
push (q: pointer to fifo, cl: pointer to cell):
Loop:
cl->next = q->tail
if CAS (&q->tail, cl->next, cl):
break
10/21/12
45. Lock-free очередь (список):
pop()
pop (q: pointer to fifo):
Loop:
cl = q->head
next = cl->next
if CAS (&q->head, cl, next):
break
return cl
10/21/12
46. ABA problem
● Поток T1 читает значение A,
● T1 вытесняется, позволяя выполняться T2,
● T2 меняет значение A на B и обратно на A,
● T1 возобновляет работу, видит, что
значение не изменилось, и продолжает…
10/21/12
47. Lock-free очередь: ABA
pop (q: pointer to fifo):
Loop:
cl = q->head
next = cl->next # cl = A, next = B
------->scheduled # pop(A), pop(B), push(A)
if CAS (&q->head, cl, next): # cl->head => B (вместо C)
break
return cl
10/21/12
48. Решение ABA
● Вводятся счетчики числа pop()'ов и push()'ей для всей структуры или
отдельных элементов и атомарно сравниваются
● Нужна операция CAS2 (Double CAS) для сравнения двух операндов:
CMPXCHG16B на x86-64
10/21/12
50. Lock-free очередь на Ring-Buffer: (1)
● Избавляемся от мьютекса
push():
while (tail_ + Q_SIZE < head_)
sched_yield();
Thread 1 Thread 2
read tail_ read tail_
read head_ read head_
------->scheduled push an element
push an element
10/21/12
51. Lock-free очередь на Ring-Buffer: (2)
● Резервируем место для вставки (→ одно чтение)
push():
unsigned long tmp_head = __sync_fetch_and_add(&head_, 1);
while (tail_ + Q_SIZE < tmp_head)
sched_yield();
ptr_array_[tmp_head & Q_MASK] = x;
10/21/12
52. Lock-free очередь на Ring-Buffer: (3)
● Per-thread current LH & LT
push():
volatile unsigned long last_head_;
volatile unsigned long last_tail_;
auto min = tail_;
for (size_t i = 0; i < n_consumers_; ++i) {
if (thr_p_[i].tail < min)
min = thr_p_[i].tail;
}
last_tail_ = min;
10/21/12
53. Lock-free очередь на Ring-Buffer: (4)
● Чтение tail i-го потока только один раз
push():
auto min = tail_;
for (size_t i = 0; i < n_consumers_; ++i) {
auto tmp_t = thr_p_[i].tail;
if (tmp_t < min)
min = tmp_t;
}
last_tail_ = min;
10/21/12
54. Lock-free очередь на Ring-Buffer: (5)
● Не оставляем текущий tail i-го потока надолго
pop():
thr_pos().tail = __sync_fetch_and_add(&tail_, 1);
// ….........
T *ret = ptr_array_[thr_pos().tail & Q_MASK];
thr_pos().tail = ULONG_MAX;
return ret;
10/21/12
55. Lock-free очередь на Ring-Buffer: (6)
● Двойная запись текущего значения tail и синхронизация с
инкрементом tail_
pop():
thr_pos().tail = tail_;
__sync_synchronize();
thr_pos().tail = __sync_fetch_and_add(&tail_, 1);
10/21/12
56. Lock-free очередь
(список vs ring-buffer)
● Ring-buffer сложнее в реализации (требуется синхронизированное
передвижение указателей на tail и head для каждого из потоков)
● Список должен быть интрузивным для избежания аллокации узлов на
каждой вставке
● Для списка нужно отдельно реализовать контроль числа элементов
● Локализация и выравнивание памяти - ?
10/21/12
57. Lock-free очередь (ограничения)
● Работает только для очередей — сканировать такие структуры
данных без блокировок нельзя
● Для очереди нужна реализация ожидания:
● usleep(1000) — помещение потока в wait queue на примерно
один такт системного таймера
● sched_yield() - busy loop на перепланирование (100% CPU
usage)
10/21/12
58. Zero copy
● Чем больше каждое из сообщений, обрабатываемых
сервером, тем актуальнее
● Решается через zero copy IO, zero copy алгоритмы и
структуры данных и архитектуру сервера в целом
● Недостатки: сильная связность между IO, аллокатором
памяти, управлением потоками
10/21/12
60. Zero-copy ввод-вывод
● Сетевой (минуя Socket API/kernel копирования)
● Дисковый (минуя буферы программы или буферный кэш ОС)
● Не “вымывает” кэши процессоров
● Имеет накладные расходы => актуально только при работе с
большими блоками данных
10/21/12
62. Zero-copy Network (I)O:
vmsplice()/splice()
● Только на Output, на Input memcpy() в ядре
● Сетевой стек пишет данные напрямую со страницы =>
перед использованием страницы снова нужно записать 2
размера буфера отправки (double-buffer write)
10/21/12
63. Zero-copy Network (I)O: пример
void zcp_send(int sd, const struct iovec *iov, size_t num) {
pipe(pipe_);
size_t data_len = 0;
for (size_t i = 0; i < num; ++i)
data_len += iov[i].iov_len;
vmsplice(pipe_[1], iov, num, 0);
for (int r; data_len; data_len -= r)
r = splice(pipe_[0], NULL, sd, NULL, data_len, 0);
}
10/21/12