Конкурентность в Go против Си с pthreads
Перевод статьи "Go Concurrency versus C and pthreads".
Недавно я принимал участие в одном программистком соревновании:
Необходимо написать программу для тестирования реакции пользователя. При запуске эта программа ожидает случайный промежуток времени от 1 до 10 секунд и выводит сообщение "GO!" в консоль.
После этого программа ожидает когда пользователь нажмет enter. В момент нажатия, замеряется промежуток времени от отображения надписи до нажатия кнопки.
Если пользователь умудрился нажать enter до сообщения "GO!", то выводится другое сообщение - "FAIL".
По сути, это упражнение на применение конкуренности(concurrency): пока одна часть логики следит за нажатием кнопки, вторая часть генерирует случайное время ожидание перед выводом сообщения и в конце нужно выполнить сравнение времени нажатия и задержки.
Так как я изучаю Go, то мне показалось, что описанная задача идеально ложится па концепцию go-рутин и каналов.
И действительно, решить эту задачу на Go очень просто.
package main
import (
    "bufio"
    "fmt"
    "math/rand"
    "os"
    "time"
)
func init() {
    rand.Seed(time.Now().UnixNano())
}
func main() {
    channel := make(chan time.Time)
    go func() {
        reader := bufio.NewReader(os.Stdin)
        reader.ReadString('\n')
        channel <- time.Now()
    }()
    time.Sleep(time.Second * time.Duration(rand.Intn(11)))
    paused := time.Now()
    fmt.Println("GO!")
    entered := <- channel
    if paused.Sub(entered) > 0 {
        fmt.Println("FAIL")
    } else {
        fmt.Printf("%v\n", time.Since(entered))
    }
}
И тут я вспомнил, что на развитие Go очень повлиял Си. Или, если быть точнее, Go это современный Си. Я заинтересовался, насколько сложнее сделать аналогичную программу на Си?
В стандартном ANSI Си нет никаких функций для работы с потоками и во втором издании K & R тоже нет никаких упоминаний о потоках. Но есть POSIX потоки(они же нити, они же треды), или pthreads, которые можно использовать как отдельную библиотеку.
В результате у нас получилось больше текста, но концептуально это практически одинаковые программы:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <pthread.h>
void *listen(void *timestamp) {
    int c;
    while((c = getc(stdin)) != EOF) {
        if( c == '\n' ) {
            gettimeofday((struct timeval *)timestamp, NULL);
            break;
        }
    }
    return NULL;
}
int main(void) {
    pthread_t listener;
    struct timeval entered, paused, diff;
    if( pthread_create(&listener, NULL, listen, &entered) ) {
        fprintf(stderr, "Error: could not create keyboard listening thread\n");
        return 1;
    }
    srand(time(NULL));
    sleep(rand() % 10 + 1);
    printf("GO!\n");
    gettimeofday(&paused, NULL);
    if( pthread_join(listener, NULL) ) {
        fprintf(stderr, "Error: could not join keyboard listening thread\n");
        return 2;
    }
    if( timercmp(&paused, &entered, >) )
        printf("FAIL\n");
    else {
        timersub(&entered, &paused, &diff);
        printf("%ld\n", (1000LL * diff.tv_sec) + (diff.tv_usec / 1000));
    }
    return 0;
}
Мы также делаем обработчик, который ожидает нажатия клавиши enter и используем функцию gettimeofday для получения текущего времени и функцию для сравнения дат, которые работают аналогично пакету time.
Создавать новые потоки с помощью pthread достаточно просто, но вместо использования каналов для обмена данными между потоками, приходится использовать передачу структуры по ссылке.
Еще одно большое отличие - при использовании pthread необходимо выполнять "присоединение"(joined) чтобы дождаться завершения работы потока.
Вот это замечательный пример с подробными объяснениями помогли мне вникнуть в суть того, как работает pthread.
И при создании обработчика, и при "присоединении" нам необходимо проверять ошибки. В нашем случае, для этой игрушечной программы я просто делаю выход в случае ошибки. Однако, для более объемных проектов, нужно будет реализовать значительно больше дополнительной логики.
Кроме некоторого различия в реализации логики этих двух примеров, существует еще много нюансов, скрытых под капотом.
Несмотря на то, что потоки, реализованные с помощью pthread, значительно легче чем процессы операционной системы, они все еще не настолько легковесные как go-рутины. Конкурентность в Go реализована на принципах взаимодействующих последовательных процессов(Communicating Sequential Processes (CSP)).
В результате, go-рутины объединены и работают на небольшом количестве системных процессов, вместо использования одного потока для каждой go-рутины.
Конечно, это не совсем честное соревнование, так как Си намного старше Go, а phthread это вообще сторонняя библиотека.
Go вобрал в себя весь опыт полученный с Си и еще много всего полезного из других языков программирования.