Конкурентность в Go против Си с pthreads

13 minute read

Перевод статьи "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 вобрал в себя весь опыт полученный с Си и еще много всего полезного из других языков программирования.