Pull to refresh

Язык Mt: C для высоконагруженных серверов

Reading time 11 min
Views 1.8K
Приветствую, хабровчане!

Я хочу предложить к обсуждению идеи о том, как упростить написание серверных программ на C введением дополнительных языковых средств. Полагаю, что эта тема может быть интересна всем разработчикам, которым приходилось иметь дело с написанием многопоточного или асинхронного кода.

На данный момент я практически завершил написание инструментария — генератора парсеров, парсера C и частично C++, — который позволяет приступить к написанию транслятора, поддерживающего языковые расширения, о которых я здесь расскажу. Но перед тем, как продолжить работу, хотелось бы посоветоваться с коллегами по цеху и найти единомышленников.

Речь пойдёт о многотопоточных асинхронных серверах. Асинхронных — то есть использующих логику обслуживания клиентов, основанную на событиях (epoll/kqueue), при которой одним потоком одновременно обслуживается множество клиентов. Многопоточных — значит готовых использовать преимущества современных многоядерных процессоров, выполняя параллельное обслуживание соединений несколькими потоками в рамках одного процесса.

Асинхронная модель обслуживания позволяет создавать серверы, наиболее подготовленные к работе с по-настоящему высокой нагрузкой. Но асинхронный (неблокирующийся) код писать сложнее, чем синхронный, блокирующийся. Кроме того, существенно сложнее писать асинхронный многопоточный код: слишком легко ошибиться при синхронизации доступа к совместно используемым данным, и слишком сложно такие ошибки выявлять. Это приводит к тому, что на практике зачастую разумнее полностью отказываются от поддержки многопоточности.

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

Идеи довольно простые, поэтому, чтобы не создавать ощущения сложности, перечислю основные тезисы по дополнениям к C. Эти дополнения я буду называть «языком Mt».

Итак, Mt — это:
  • Автоматическое управление мьютексами для синхронизации доступа к объектам;
  • Асинхронные цепочки обработки, записываемые как обычные последовательные функции;
  • Аннотирование — дополнительное описания элементов программы для статического анализа исходных текстов;
  • Встроенный механизм подсчёта ссылок;
  • Заимствование наиболее полезных возможностей из C++;
  • Совместимость с C, жёсткая привязка к C ABI. Первоначальная реализация — транслятор из Mt в C.
Поясню, почему в качестве базы выбран C, а не C++. Основная причина — существенно более высокая сложность C++ с точки зрения семантического анализа исходных текстов. На протяжении последнего года я предпринял попытку написать парсер C++ и систему статического анализа C++-кода. Результат — парсер C и разбор семантики в стиле C дались довольно легко, а разбор C++ далёк от завершения: объём работы слишком велик. Поэтому я решил, что можно добиться хороших практических результатов, опираясь на чистый C и дополнив его теми возможностями C++, которые достаточно просты в реализации.

Далее расскажу о каждом дополнении подробнее. Некоторые примеры кода привожу на C++, потому что так их легче соотносить с эквивалентным кодом на Mt.

1. Автоматическое управление мьютексами


Рассмотрим распространённую схему обработки запросов. Пусть у нас есть некоторый объект класса MyFrontend, представляющий собой реализацию нашего сервера. После получения и декодирования запроса от клиента мы вызываем метод серверного объекта processRequest(). Этот метод выполняет некоторые операции, вызывающие изменение внутреннего состояния сервера. Положим для примера, что в результате обработки запроса увеличивается счётчик кол-ва запросов num_requests:

    class MyFrontend
    {
    private:
        Mutex state_mutex;
        unsigned num_requests;

    public:
        void processRequest ()
        {
            state_mutex.lock ();
            ++ num_requests;
            state_mutex.unlock ();
        }
    };


Mt освобождает нас от необходимости ручной синхронизации:

    async object MyFrontend
    {
    private:
        unsigned num_requests;

    public:
        async void processRequest ()
        {
            ++ num_requests;
        }
    };


async object объявляет класс объектов, безопасных с точки зрения многопоточности. Такие объекты будем называть асинхронными. Приведённый пример кода на Mt транслируется в код, эквивалентный приведённому примеру на C++.

Теперь допустим, что для обработки запроса MyFrontend должен вызвать метод doSomething() объекта MyBackend, передав ему в качестве параметра счётчик обращений к бэкэнду num_back. Кроме того, мы не хотим задумываться о возможности взаимоблокировок и хотим, чтобы всё работало с нерекурсивными мьютексами. Для этого нам нужно освобождать state_mutex перед обращением к MyBackend:

    class MyFrontend
    {
    private:
        Mutex state_mutex;
        unsigned num_requests;
        unsigned num_back;
        MyBackend *backend;

    public:
        void processRequest ()
        {
            state_mutex.lock ();
            ++ num_back;
            unsigned tmp_num_back = tmp_num_back;
            state_mutex.unlock ();

            backend->doSomething (tmp_num_back);

            state_mutex.lock ();
            ++ num_requests;
            state_mutex.unlock ();
        }
    };


То же самое на Mt:

    async object MyFrontend
    {
    private:
        unsigned num_requests;
        unsigned num_back;
        MyBackend *backend;

    public:
        async void processRequest ()
        {
            call backend->doSomething (++ num_back);
            ++ num_requests;
        }
    };


Транслятор Mt самостоятельно вставляет операции со state_mutex и вводит временную переменную для передачи параметра. Оператор call введён для того, чтобы подчеркнуть, что на время вызова асинхронного метода другого объекта мы освобождаем блокировку state_mutex. Кроме того, это позволяет нам явно запретить вызов асинхронных методов в выражениях.

Помимо приведённых примеров автоматизируется ряд других случаев использования блокировок: ожидание событий (события обычно используются в паре с мьютексом, защищающим условие завершения ожидания), вызов асинхронного метода объекта из другого метода того же объекта.

Таким образом, мы сводим к минимуму риск ошибиться при управлении простой блокировкой на состояние объекта. По моим наблюдениям, если синхронизация доступа к объекту осуществляется с использованием не одной, а нескольких блокировок, то скорее всего имеет место неудачное проектирование: следует или разделить объект на несколько самостоятельных объектов, или слить блокировки в одну. Поэтому, автоматизируя простейший случай с одним мьютексом, мы решаем большую часть проблем синхронизации. Возможность выполнять синхронизацию в ручном режиме и с использованием других примитивов при этом
сохраняется.

2. Асинхронные цепочки обработки


На мой взгляд, это наиболее интересная и необычная идея. Объясню на примере, иллюстрирующем работу реального сервиса.

Допустим, наш сервер — это фронтэнд фотохостига. Мы хотим реализовать отдачу клиентам HTML-страниц с фотографиями пользователей. На странице отображается информация из анкеты владельца фотографии и сама фотография. Фотографии могут смотреть только друзья владельцев фото. Таким образом, для того, чтобы ответить на HTTP-запрос, нужно собрать нужную информацию, опросив ряд внутренних сервисов.

Алгоритм обработки запроса:
  1. Отправить серверу проверки авторизации cookie пользователя, чтобы идентифицировать его (check_auth);
  2. После проверки авторизации отправить серверу, обслуживающему социальный граф, запрос на проверку, являются ли пользователи друзьями (check_friends);
  3. Отправить в хранилище анкетных данных запрос на владельца фотографии (get_userinfo);
  4. После получения ответов от check_friends и get_userinfo сформировать HTML-страницу и отправить её клиенту.
Операция 2 может быть выполнена только после получения ответа на запрос 1. Запрос 3 не зависит от запросов 1 и 2 и мы хотим, чтобы он выполнялся параллельно с ними.

Сделаем набросок реализации на C. В этот пример можно не вчитываться. Его задача — показать, насколько громоздка ручная реализация даже такой несложной асинхронной цепочки обработки:

    // Структура состояния асинхронной цепочки.
    typedef struct {
        // "Родительский" класс, управляющий счётчиком ссылок.
        MReferenced parent_referenced;
        // Блокировка на доступ к элементам состояния.
        MMutex state_mutex;

        uint64_t owner_id;
        uint32_t photo_id;

        // Если 'true', то прерываем цепочку.
        bool cancel;

        // Получили ответ от get_userinfo?
        bool got_userinfo;
        // Карма владельца фото (анкетные данные).
        uint32_t owner_carma;

        // Получили ответ от check_friends?
        bool got_friends;
    } HttpPhotoData;

    static void http_photo_data_init (HttpPhotoData *data)
    {
        m_referenced_init ((MReferenced*) data);
        m_mutex_init (&data->state_mutex);

        data->cancel = false;
        data->got_userinfo = false;
        data->got_friends = false;
    }

    static void http_photo__get_userinfo_ret (uin32_t owner_carma,
                                              void *_data);

    static void http_photo__check_auth_ret (uint64_t client_id,
                                            void *_data);

    static void http_photo__end (HttpPhotoData *data);

    // Точка входа в асинхронную цепочку.
    void http_photo (char *cookie_str,
                     uint64_t owner_id,
                     uint32_t photo_id)
    {
        HttpPhotoData *data = m_malloc (sizeof (HttpPhotoData));
        http_photo_data_init (data);

        data->owner_id = owner_id;
        data->photo_id = photo_id;

        {
            m_referenced_ref ((MReferenced*) data);

            MCallbackDesc cb;
            m_callback_desc_init_refdata (&cb, http_photod__get_userinfo_ret, data);

            get_userinfo (&cb, owner_id);
        }

        {
            m_referenced_ref ((MReferenced*) data);

            MCallbackDesc cb;
            m_callback_desc_init_refdata (&cb, http_photod__check_auth_ret, data);

            check_auth (&cb, cookie_str);
        }

        m_referenced_unref ((MReferenced*) data);
    }

    // Ответ от get_userinfo.
    static void http_photo__get_userinfo_ret (uint32_t owner_carma,
                                              void *_data)
    {
        HttpPhotoData *data = (HttpPhotoData*) _data;

        m_mutex_lock (&data->state_mutex);

        if (data->cancel) {
            m_mutex_unlock (&data->state_mutex);
            return;
        }

        data->owner_carma = owner_carma;
        data->got_userinfo = true;

        if (data->got_friends) {
            // Все данные для ответа собраны, отвечаем клиенту.
            http_photo_end (data);
        }

        m_mutex_unlock (&data->state_mutex);
    }

    // Ответ от check_auth.
    static void http_photo__check_auth_ret (uint64_t client_id,
                                            void *_data)
    {
        HttpPhotoData *data = (HttpPhotoData*) _data;

        m_referenced_ref ((MReferenced*) data);

        MCallbackDesc cb;
        m_callback_desc_init_refdata (&cb, http_photo__check_friends_ret, data);

        check_friends (&cb, client_id, data->owner_id);
    }

    // Ответ от check_friends.
    static void http_photo__check_friends_ret (bool friends,
                                               void *_data)
    {
        HttpPhotoData *data = (HttpPhotoData*) _data;

        m_mutex_lock (&data->state_mutex);
        if (data->cancel) {
            m_mutex_unlock (&data->state_mutex);
            return;
        }

        data->got_friends = true;

        if (!friends) {
            // Отказано в доступе. Прерываем цепочку.
            data->cancel = true;
            m_mutex_unlock (&data->state_mutex);
            return;
        }

        if (data->got_userinfo) {
            // Все данные для ответа собраны, отвечаем клиенту.
            http_photo_end (data);
        }

        m_mutex_unlock (data->state_mutex);
    }

    // Отправка ответа клиенту.
    static void http_photo_end (HttpPhotoData *data)
    {
        photo_send_page (data->owner_id, data->photo_id, data->owner_carma);
    }


Получившийся код сложен, его трудно читать. Большая часть времени при его написании была потрачена на рутинное обеспечение нужного порядка выполнения операций.

Эквивалентная реализация на Mt:

    async chain void http_photo (char *cookie_str,
                                 uint64_t owner_id,
                                 uint32_t photo_id)
    {
        async call GetUserinfoCall get_userinfo (owner_id);

        async call check_auth (cookie);
            gives uint64_t client_id;

        async call check_friends (client_id, owner_id)
            gives bool friends;

        if (!friends) {
          // Доступ запрещён
            return;
        }

        GetUserinfoCall gives uint32_t owner_carma;

        // Отправляем HTTP-ответ со страницей фото.
        photo_send_page (owner_id, photo_id, owner_carma);
    }


Обратите внимание, что мы практически полностью освободились от рутинной работы по организации нужного порядка исполнения. Кроме того, сгенерированный C-код можно будет использовать в многопоточных программах.

В приведённом примере использованы два новых оператора:
  • Оператор async call описывает асинхронный вызов. Функции, которые можно использовать с этим оператором, должны иметь особую сигнатуру: первый параметр — callback, который будет вызван по завершении вызова. Callback завершения должен обязательно быть вызван при любом исходе, будь то успешное выполнение вызова, внутренняя ошибка или таймаут.
  • Оператор gives задаёт точку, в которой нужно дождаться завершения указанного асинхронного вызова. gives вводит в область видимости список возвращаемых вызовом значений. Если операторы async call и gives должны быть разнесены, то в async call вызов именуется и в gives указывается имя вызова (см. GetUserinfoCall в приведённом примере).
Кроме продемонстрированных операторов, предусматривается возможность ранней обработки результатов асинхронных вызовов, чтобы можно было прервать цепочку при возникновении ошибки до того, как мы дойдём до соответствующего неудавшемуся вызову оператора gives. Также можно задавать дополнительный деструктор для данных, входящих в структуру состояния цепочки.

Основная работа, которую выполняет Mt при трансляции асинхронных цепочек в C, заключается в следующем:
  • Определяет набор переменных, которые нужно хранить в структуре состояния цепочки (HttpPhotoData);
  • Дробит функцию на более мелкие. Точками разрыва являются операторы async call;
  • Обеспечивает нужный порядок выполнения, преобразуя операторы C, нарушенные операторами async call и gives, в эквивалентные асинхронные конструкции по фиксированным правилам.
Можно показать, что каждая из этих операций хорошо поддаётся автоматизации.

Параллельное выполнение сложных цепочек операций, непредставимых одной функцией в Mt, можно реализовать, использовав несколько разных асинхронных цепочек. То есть если ветвления в цепочке так сложны, что их не удаётся описать одной функцией, то можно построить описание как композицию нескольких функций.

С помощью Mt мы можем описывать циклы и условные переходы, разрываемые ожиданием ответов от асинхронных вызовов, в такой же простой и привычной форме, как при написании обычного кода. Допустим, мы хотим поискать значение в трёх независимых кэшах поочерёдно:

    async chain void search (int key)
    {
        for (int i = 0; i < 3; i++) {
            async call search_cache (key, i /* cache id */)
                gives int value;

            if (value > 0) {
                printf ("VALUE: %d\n", value);
                break;
            }
        }
    }


Таким образом, для асинхронных цепочек обработки Mt может обеспечить качественный скачок в выразительности и удобстве использования по сравнению с C.

3. Аннотирование


Аннотирование — это введение в код дополнительной вспомогательной информации с помощью аннотирующих слов. Аннотирующие слова могут стоять везде, где могут стоять квалификаторы const и volatile. Все аннотирующие слова начинаются с символа '$'. Примеры:
    int * $nonnull ptr;     // указатель не может быть равен NULL
    MyObject * $heap myobj; // объект должен быть выделен с помощью аллокатора


Транслятор Mt является также основой для системы статической проверки кода, которая использует аннотирование для дополнительных проверок.

4. Заимствование возможностей C++


С точки зрения реализации транслятора, наиболее сложной особенностью C++ по сравнению с C является зависимость семантического значения языковых конструкций от типов данных аргументов. Это касается, в первую очередь, правил перегрузки имён функций (включая перегрузку операторов) и правил выбора шаблонов с учётом частичных специализаций. Такая зависимость означает, что для того, чтобы определить, какая именно функция или шаблон используется в варыжении, нужно вывести типы данных всех аргументов функции или шаблона. Это, в свою очередь, требует полной и точной реализации правил приведения типов. Всё это составляет довольно значительный объём работы.

В то же время, перегрузка имён — спорное свойство языка. Например, от неё отказались создатели языка Go, о чём есть содержательная запись в FAQ.

В Mt не будет перегрузки имён функций и перегрузки операторов, множественного наследования, исключений, RTTI. Шаблоны без частичной специализации реализовать не так сложно, и этого достаточно для того, чтобы можно было создавать обобщённые контейнерные классы, поэтому шаблоны целесообразно включить в язык. В отсутствие перегрузки имён шаблоны станут гораздо более простым и лаконичным инструментом: таких возможностей для метапрограммирования, как в C++, не будет.

5. Ссылки и слабые ссылки


Отсутствие перегрузки операторов не даёт возможность реализовать в Mt удобные аналоги «умных указателей», таких как std::auto_ptr и boost::shared_ptr. Взамен на уровне языка предлагается механизм подсчёта ссылок:
    int  @a; // ссылка
    int @@b; // слабая ссылка


В базовом варианте используется простой подсчёт ссылок. Счётчики ссылок встроены
в каждый синхронный или асинхронный объект Mt (object и async object). При таком подходе к управлению ссылками программисту следует избегать создания циклических ссылок, которые приводят к утечке памяти. Для разрыва циклов используются слабые ссылки. В перспективе можно предусмотреть возможность использования более сложного сборщика мусора, но сейчас я не вижу в этом необходимости.

В отсутствие перегрузки операторов нет необходимости поддерживать в Mt ссылки C++ (вида int &a).

Отмечу, что реализация ссылок на уровне языка позволяет получить более полный и удобный механизм, чем можно построить на шаблонах C++.

Заключение


Я обозначил основные возможности, которые собираюсь включить в язык Mt. Помимо них в проработке есть и другие идеи, направленные на упрощение разработки и уменьшение ожидаемого количества ошибок в программах.

Транслятор языка Mt — проект с открытым исходным кодом. В данный момент он существует в виде парсера подмножества языка C++ и живёт в svn-репозитории mync.svn.sourceforge.net/svnroot/mync/trunk под рабочим названием «scruffy».

Если у вас есть мысли и идеи по добавлению или изменению определённых свойств языков С/C++, то предлагаю их озвучить: возможно, пришло время для их практической проверки.

Приглашаю к участию в проекте. Если вы ищете тему для самостоятельной работы, которая могла бы дать новые знания и опыт, а в случае успеха превратиться в качественный и успешный продукт, то компилятор нового языка может стать отличным выбором. Если же проблемы, на которые нацелен Mt, для вас не очень актуальны, то, возможно, вас заинтересует разработка парсера C++ и системы статического анализа программ на C/C++.

Вместе мы сможем больше!

Благодарю за внимание.
Tags:
Hubs:
+53
Comments 73
Comments Comments 73

Articles