23 марта 2010

Синхронизация в потоках POSIX: семафоры, мютексы, спин-локи и атомарные операции

Для потоков POSIX я знаю 3 наиболее популярных способа синхронизации:
- семафоры, наиболее тяжеловесный, но и наиболее функциональный способ; семафор предоставляет в распоряжение программиста потокобезопасный счетчик с возможностью блокирования текущего потока в случае, когда значение счетчика доходит до 0; при всех операциях с семафором происходит переключение в режим ядра и обратно, что, естественно, плохо сказывается на производительности;
- мьютексы, фактически семафоры на 2 значения — истина или ложь, заблокирован ли вход в критическую секцию или нет; реализация POSIX обещает, что только в режиме ожидания происходит переключение в ядро, а разблокирование выполняется в режиме пользователя; это, по идее должно несколько снизить вычислительные расходы на синхронизацию;
- спин-локи, аналог мьютекса, но и блокирование и разблокирование происходят в процессе пользователя; за это приходится платить активным ожиданием в момент блокирования (это означает, что поток в цикле будет непрерывно опрашивать переменную синхронизации), которое нагружает процессор на 100%; понятно, что использовать спин-локи имеет смысл только для очень коротких критических секций.
Чтобы протестировать производительность с использованием разных методов синхронизации, я написал простую программу: в N потоках (задается параметрами командной строки) параллельно 1.000.000 / N раз происходит увеличение глобальной переменной на 1. В результате выполнения такой программы должен получиться 1.000.000. Естественно, если вообще не использовать синхронизацию, результат будет несколько отличаться в меньшую сторону. Далее я привел 3 варианта программы с использованием разных способов синхронизации, а также 4-й вариант, в котором применена атомарная операция __sync_fetch_and_add (при компиляции надо добавлять ключи -DSEMAPHORE, -DMUTEX и -DSPIN, либо без ключей для атомарной операции). При компиляции такая инструкция приводит к добавлению перед ассемблерной командой прибавления префикса lock, гарантирующего атомарность на физическом уровне.

#include <iostream>
#include <pthread.h>
#include <cstdlib>

#ifdef SEMAPHORE
    #include <semaphore.h>
sem_t s;
    #define init sem_init(&s, 0, 1)
    #define add sem_wait(&s); \
            ++a; \
            sem_post(&s)
    #define destroy sem_destroy(&s)
#elif MUTEX
pthread_mutex_t m;
    #define init pthread_mutex_init(&m, NULL)
    #define add pthread_mutex_lock(&m); \
            ++a; \
            pthread_mutex_unlock(&m)
    #define destroy pthread_mutex_destroy(&m)
#elif SPIN
pthread_spinlock_t l;
    #define init pthread_spin_init(&l, 0)
    #define add pthread_spin_lock(&l); \
            ++a; \
            pthread_spin_unlock(&l)
    #define destroy pthread_spin_destroy(&l)
#else
    #define init
    #define add __sync_fetch_and_add(&a, 1);
    #define destroy
#endif

int a = 0;
const int N = 1000000;
int P = 1, M = N / P;

void* f(void*){
for(int i = 0; i < M; ++i) {
add;
}
}

int main(int argc, char *argv[]){
if (argc > 1) {
P = atoi(argv[1]);
M = N / P;
}

pthread_t t[P];
init;
for(int i = 0; i < P; ++i) {
pthread_create(&t[i], NULL, f, NULL);
}
for(int i = 0; i < P; ++i) {
pthread_join(t[i], NULL);
}
std::cout << a << std::endl;
destroy;
return 0;
}


В результате запуска этой программы с различными вариантами синхронизации на машине с 2-хядерным процессором для количества потоков 1, 2, 5, 10, 50 и 100, я получил следующее время выполнения:



Естественно, что атомарные операции оказались самыми быстрыми и, при этом, результат почти не зависит от количества потоков, семафоры несколько медленнее мьютексов, но зависимость от числа потоков незначительная. А вот спинлоки, показавшие хороший результат для 1 и 2-х потоков, оказываются значительно хуже с ростом числа потоков. Очевидно, что 2-х ядерный процессор может обслуживать только 2 потока в состоянии активного ожидания, что и объясняет полученный результат.

Комментариев нет: