Не бойтесь велосипедов. Или еще один Grand Central Dispatch (GCD) на C++11

    ИМХО (Имею Мнение Хрен Оспоришь)


    С моей точки зрения самое полезное, что может сделать программист для повышения своего профессионального уровня — это написание велосипедов. Велосипедостроение — очень увлекательный процесс. Иногда он увлекает больше, чем задача, ради которой сам велосипед и затевался. При написании велосипеда (под велосипедом я понимаю реализацию уже существующего) происходит более глубокое понимание уже существующих решений и техник.


    Мотивация


    Вот уже больше трех лет моим основным рабочим языком является objective-c, и когда я только начал писать на нем меня приятно удивил продуманный высокоуровневый API для работы с многопоточностью NSOperationQueue, а позже — GCD, который по моему мнению является квинтэссенцией лаконичности и понятности для Thread concurrency. И вот недавние статьи на Хабре: Техника написания аналога await/async из C# для C++ и Thread concurrency C++11. Они и заставили посмотреть на те новые плюшки, которые предоставляет C++ для работы с многопоточностью. И большинство из них (тот-же std::future) для меня выглядят примерно так:


    Домыслы и хотелки


    Вот типичный сценарий, в котором я использую многопоточность в своих приложениях:
    • асинхронно получить какие-то данные (файлы/сеть);
    • распарсить/подотовить полученные данные;
    • вернуть данные в вызываемый поток (например, в главный поток и обновить UI).

    Удобно, чтобы для каждой из этих операций была своя очередь.
    А еще более удобно, когда все это собрано в одном месте, а не разбросано по пяти исходным файлам. Что-то на подобии:
    file_io_queue.async([=]{
        file_data = get_data_from_file( file_name );
        parser_queue.async([=]{
            parsed_data = parse( file_data );
            main_queue.async([=]{
                update_ui_with_new_data( parsed_data ) ;
            });
        });
    });
    Этот код читается как абсолютно линейный, синхронный код. Он описывает логику того, как будут происходить изменения в данных. Для меня, по большому счету, без разницы, в каком потоке будет выполняться вычитка файла, в каком — его парсинг. Главное — последовательность этих операций. Я могу вызвать предыдущий код 100500 раз для 100500 файлов.

    Очевидное решение — реализация шаблона пулл потоков. Но почти все реализации, виденные мной на просторах интернетиков, предлагают использовать один std::thread для одной очереди. С моей точки зрения это не есть хорошо. Например, нужно хранить инстанс самой очереди все время, пока выполняются асинхронные операции. Создание исттанса std::thread на порядки более затратная операция, чем захват/освобождение мютекса. Когда нам стоит уничтожать очередь? Да и простаивающее большое количество потоков в то время, когда очередь не используется — не айс.
    Мы сделаем по другому. У нас будет N-ное количество потоков (std::thread) и список легковесных очередей с приоритетами. Когда мы добавляем задачу в очередь, то оповещаем поток о том, что появилась новая задача. Поток берет самую высокоприоритетную задачу и выполняет ее. Если задача с таким приоритетом уже выполняется, то берет более низкоприоритетную задачу. Если таких нет — ждет.


    Код


    Приступим:
    Очередь
    namespace dispatch{
        typedef std::function<void ()> function;
        struct queue {
            typedef long priority; // Наш приоритет. Пусть это будет целое число
            const queue::priority queue_priority; 
    
            static std::shared_ptr<queue> main_queue() ; // Об этом ниже
    
            virtual void async(function) const; // Собственно метод для добавления задачи в очередь
    
            queue(queue::priority priority) : queue_priority(priority) {};
        };
    }

    Реализация метода async
    просто перенаправляет вызов в thread pool:
        void queue::async(dispatch::function task) const {
            thread_pool::shared_pool()->push_task_with_priority(task, this->queue_priority);
        };

    Вся работа будет происходить в нашем
    Пуле потоков:
        struct queue_impl{
            const queue::priority priority;
            std::queue<function> tasks;
            bool is_running;
            queue_impl(queue::priority priority): priority(priority){};
        };
        
        struct thread_pool{
            thread_pool();
            static std::shared_ptr<thread_pool>& shared_pool(); // thread_pool
            virtual ~thread_pool();
            
            bool stop;
            
            typedef std::shared_ptr<queue_impl> queue_ptr; 
            
            void push_task_with_priority(const function&, queue::priority);// Добавляем задачу в очередь с приоритетом
            bool get_free_queue(queue_ptr*) const;          // Ищем очередь, задачи из которой не исполняются прямо сейчас
            void start_task_in_queue(const queue_ptr&);  // Отмечаем очередь как исполняющуюся
            void stop_task_in_queue(const queue_ptr&);  // Снимаем отметку
            
            std::mutex mutex; // Мютекс для синхронизации пула
            std::map<queue::priority, queue_ptr> queues; // Здесь хранятся очереди по приоритетам
            
            std::mutex main_thread_mutex;
            std::queue<dispatch::function> main_queue;
            
            std::condition_variable condition;
            std::vector<std::thread> threads; // Массив реальных потоков, которые и будут выполнять задачи
            
            dispatch::function main_loop_need_update;
            void add_worker(); // Добавляем еще один поток
        };

    Рассмотрим методы по порядку. Нам необходимо найти свободную очередь с максимальным приоритетом:
    найти свободную очередь с максимальным приоритетом:
       bool thread_pool::get_free_queue(queue_ptr* out_queue) const {
            //  проходим по всем очередям с приоритетом от максимального до минимального
           auto finded = std::find_if(queues.rbegin(), queues.rend(), [](const std::pair<queue::priority, queue_ptr>& iter){
                                             return ! iter.second->is_running; // и находим первую свободную очередь
                                         });
            
            bool is_free_queue_exist = (finded != queues.rend());
            if (is_free_queue_exist)
                *out_queue = finded->second;
            
            return  is_free_queue_exist;
        }

    Добавляем задачу в очередь
        void thread_pool::push_task_with_priority(const function& task, queue::priority priority){
            {
                std::unique_lock<std::mutex> lock(mutex); // Захватваем мютекс
    
                // Добавляем задачу в очередь. Если очереди нет - создаем ее
                auto queue = queues[priority];
                if (!queue){
                    queue = std::make_shared<dispatch::queue_impl>(priority);
                    queues[priority] = queue;
                }
                queue->tasks.push(task);
    
                // Если необходимо, то добавляем потоки
                unsigned max_number_of_threads = std::max<unsigned>(std::thread::hardware_concurrency(), 2);
                unsigned number_of_threads_required = round(log(queues.size()) + 1);
                while (threads.size() < std::min<unsigned>(max_number_of_threads, number_of_threads_required)){
                    add_worker();
                }
            }
            condition.notify_one(); // Оповещаем поток, что мы добавили задачу в очередь
        }

    Отмечаем задачу как выполненную
        void thread_pool::stop_task_in_queue(const queue_ptr& queue){
            {
                std::unique_lock<std::mutex> lock(mutex);
               // Отмечаем задачу как выполненную. Если очередь пуста - убираем ее из списка очередей
                queue->is_running = false;
                if ( queue->tasks.size() ==0 ){
                    queues.erase(queues.find(queue->queue_priority));
                }
            }
            condition.notify_one(); // Оповещаем поток, что одна из задач выполненна
        }

    И, собственно, сам поток:
        void thread_pool::add_worker(){
            threads.push_back(std::thread([=]{
                                  dispatch::function task;
                                  thread_pool::queue_ptr queue;
                                  while(true){
                                      {
                                          std::unique_lock<std::mutex> lock(mutex); // Пытаемся захватить мютекс
                                          
                                          while(!stop && !get_free_queue(&queue)) // Если нет свободных очередей
                                              condition.wait(lock);                   // То ждем оповещения
                                       
                                          if(stop) // Если пулл потоков был остановлен, то завершаемся
                                              return;
    
                                          task = queue->tasks.front(); // Забираем задачу из очереди
                                          queue->tasks.pop(); 
    
                                          start_task_in_queue(queue); // Отмечаем очередь как занятую
                                      }
                                      task(); // Исполняем задачу
                                      stop_task_in_queue(queue); // Отмечаем очередь как свободную
                                  }
                              }));
        }


    Main Thread и Run Loop




    В С++ нет такого понятия как главный поток. Но на этой концепции построены практически все UI приложения. UI мы можем изменять только из главного потока. Значит, нам нужно либо самим организовать Run Loop, либо вклиниться в уже существующий.

    Для начала создадим отдельную очередь для «главного потока»:
    Main Queue
        struct main_queue : queue{
            virtual void async(dispatch::function task) const override;
            main_queue(): queue(0) {};
        };
    
        std::shared_ptr<queue> queue::main_queue(){
            return std::static_pointer_cast<dispatch::queue>(std::make_shared<dispatch::main_queue>());
        }

    А в методе async будем добавлять задачи в
    отдельную очередь
        void main_queue::async(dispatch::function task) const {
            auto pool = thread_pool::shared_pool();
            std::unique_lock<std::mutex> lock(pool->main_thread_mutex);
            pool->main_queue.push(task);
            if (pool->main_loop_need_update != nullptr)
                pool->main_loop_need_update();
        }

    Ну и нам необходима функция, которая будет вызываться из главного потока:
    Код
        void process_main_loop() {
            auto pool = thread_pool::shared_pool();
            std::unique_lock<std::mutex> lock(pool->main_thread_mutex);
            while (!pool->main_queue.empty()) {
                auto task = pool->main_queue.front();
                pool->main_queue.pop();
                task();
            }
        }



    Теперь только два вопроса: «Как?» и «Зачем?»


    Сначала «Зачем?»: C++ довольно часто используется для написания кросс-платформенного ПО. В угоду переносимости от многих удобных вещей необходимо отказаться. GCD очень удобная библиотека, предоставляющая простой, наглядный и удобный способ управления асинхронными очередями.
    На вопрос «Как?» нет однозначного ответа. Вклиниться ранлуп можно по разному. Многие системы предастовляют API для этого. Например, в iOS есть «performSelectorOnMainThread:». Нам нужно лишь задать коллбэк через dispatch::set_main_loop_process_callback:
    -(void)dispatchMainThread{
        dispatch::process_main_loop();
    }
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{
        dispatch::set_main_loop_process_callback([=]{
            [self performSelectorOnMainThread:@selector(dispatchMainThread) withObject:nil waitUntilDone:NO];
        });
        return YES;
    }

    Если-же мы сами организуем наш собственный ранлуп, то можно сделать что-то такое:
        void main_loop(dispatch::function main_loop_function);
    
        void main_loop(dispatch::function main_loop_function){
            auto main_queue = queue::main_queue();
            while (!thread_pool::shared_pool()->stop) {
                main_queue->async(main_loop_function);
                process_main_loop();
            }
        }



    А теперь собственно то, ради чего это и затевалось:


    Создадим 6 очередей и запихнем в каждую по 6 заданий:
       auto main_thread_id = std::this_thread::get_id();
        for (unsigned task = 0; task < 6; ++task)
        for (unsigned priority = 0; priority < 6; ++priority){
            dispatch::queue(priority).async([=]{
                assert(std::this_thread::get_id() != main_thread_id);
                std::string task_string = std::to_string(task);
                std::string palceholder(1+priority*5, ' ');
                dispatch::queue::main_queue()->async([=]{
                    assert(std::this_thread::get_id() == main_thread_id);
                    std::cout << palceholder << task_string << std::endl;
                });
            });
        }

    Получим примерно такую картинку
    
                              0
                              1
                         0
                    0
                              2
                         1
                    1
                              3
                         2
                    2
                              4
                         3
                    3
                              5
                         4
                    4
               0
                         5
                    5
               1
          0
     0
               2
          1
     1
               3
          2
     2
               4
          3
     3
               5
          4
     4
          5
     5
    

    «Столбик» представляет собой очередь. Чем правее, тем более высокий приоритет у очереди. Линия — это коллбэки на «главный поток».

    Ну и код для iOS:
        for (int i = 0; i < 20; ++i){
            dispatch::queue(dispatch::QUEUE_PRIORITY::DEFAULT).async([=]{
                NSAssert(![NSThread isMainThread], nil);
                std::string first_string = std::to_string(i);
                dispatch::queue::main_queue()->async([=]{
                    NSAssert([NSThread isMainThread], nil);
                    std::string second_string = std::to_string(i+1);
                    std::cout << first_string << " -> " << second_string << std::endl;
                    [self.tableView reloadData]; // Делаем что-то с UI. То, что можно делать только из главного потока
                });
            });
        }


    Заключение


    Заключения не будет. Этот велосипед писался исключительно с целью пощупать многопоточность в C++11. Код представляет собой чуть более 200 строк не особо хорошего С++ кода, представлен на гитхабе. Проверялся на clang++ 3.3, g++-4.7 / g++-4.8 и компилятором 2012 Visual Studio. То есть основные компиляторы уже в достаточной мере поддерживают C++11.

    Пы.Сы. Призывая писать свои велосипеды я отнюдь не призываю их использовать на боевых проектах. Хотя, с другой стороны, как еще велосипед может превратиться во что-то серьезное?

    Ну и еще пара велосипедов, которые я не придумал куда впихнуть в статье


    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 26
    • 0
      Я захотел собрать велосипед с первой картинки!
      • –1
        Удобно, чтобы для каждой из этих операций была своя очередь.

        А в чём именно удобство? Мне это совершенно не очевидно.

        Очевидное решение — реализация шаблона пулл потоков.

        Нуу например std::async в реализации MS именно так и работает.
        • 0
          А в чём именно удобство? Мне это совершенно не очевидно
          SRP — жеж. Ну и отсутствие возможности для dead-lock'а — если вся работа с одним ресурсом (файловой системой, к примеру) идет через одну очередь.
          Нуу например std::async в реализации MS именно так и работает.
          std::async — это тот еще костыль ИМХО. Получение значения оттуда через std::future полностью блокирует текущий поток (что для Main Thread не приемлемо в принципе). А я не хочу блокироваться, я хочу получить уведомление о том, что все закончилось в контексте текущей очереди тогда, когда данные будут получены.
          • 0
            SRP — жеж. Ну и отсутствие возможности для dead-lock'а — если вся работа с одним ресурсом (файловой системой, к примеру) идет через одну очередь.

            Согласно SRP нам нужна отдельная сущность «пул потоков». Зачем нам надо много пулов потоков я пока так и не понял. Насчёт dead-lock'a… Во-первых это не так просто, т.к. ресурс у пула потоков наверняка не один (как минимум по числу аппаратных потоков). Ну и если всё же есть его вероятность (т.е. имеем много спящих потоков), то зачем тогда вообще использовать пул, а не просто отдельные потоки на каждую задачу?
            std::async — это тот еще костыль ИМХО. Получение значения оттуда через std::future полностью блокирует текущий поток (что для Main Thread не приемлемо в принципе). А я не хочу блокироваться, я хочу получить уведомление о том, что все закончилось в контексте текущей очереди тогда, когда данные будут получены.

            Ничего подобного. При использование std::async совершенно не обязательно блокировать вызывающий поток. Блокирование — это всего лишь один из сценариев. Можно и опрашивать, а можно и ждать уведомления. Кстати, как раз в той статье, на которую вы поместили ссылку в начале, были явно показаны все 3 варианта.
            • 0
              Зачем нам надо много пулов потоков я пока так и не понял
              У нас только один пулл потоков. Он синглтон
              • 0
                Нууу хорошо, пусть один пул с несколькими очередями (хотя по сути это… ) в него. Всё равно не понятно зачем их несколько. Можно какие-то практические примеры? )

                P.S. Как вы понимаете у меня вопросы/претензии не к вашему велосипедику, а к изначальной схеме…
                • 0
                  Хорошо. Представьте, что вам нужно вычитать N файлов и распарсить их. В другом участке кода вам нужно послать запрос по сети (пусть синхронно, не суть), а еще вам нужно сгенерировать M изображений в фоне (или вычитать их из сети), а потом изобразить их в главном потоке. Потом… тут подставьте еще много полезных, нужных вещей, которые так нужны современным приложениям.
                  Вы можете создать по потоку для каждого типа операций. И устроить очередь в этом отдельном потоке. При уничтожении этой очереди удаляем поток.
                  Но создание/удаление потока довольно дорогостоящая операция. Проще держать уже готовый поток, который будет обрабатывать задания из многих очередей. В этом и есть смысл пула потоков. У нас может быть одновременно очень много очередей, Добавление/удаление очередей и добавление заданий в очередь очень дешевая операция. Задания выполняются согласно приоритетам.
                  Можете воспринимать очереди как очень легковесные потоки
                  • 0
                    Вы похоже не понимаете что я спрашиваю. Я не спрашиваю зачем нам многопоточность или зачем нужны пулы потоков в принципе. Мне интересно почему мы не можем написать просто так (в вашей терминологии):
                    async_queue.async([=]{
                        file_data = get_data_from_file( file_name );
                        async_queue.async([=]{
                            parsed_data = parse( file_data );
                            main_queue.async([=]{
                                update_ui_with_new_data( parsed_data ) ;
                            });
                        });
                    });
                    

                    или вообще так:
                    async_queue.async([=]{
                        file_data = get_data_from_file( file_name );
                        parsed_data = parse( file_data );
                        main_queue.async([=]{
                            update_ui_with_new_data( parsed_data ) ;
                        });
                    });
                    

                    Т.е. зачем более одной очереди (отправку исполнения в UI поток рассматриваем как отдельный особый случай).

                    Ну и безотносительно всего этого… Создание/удаление потока имеют обычно ничтожные затраты на фоне самой задачи (уж точно на фоне перечисленных вами в последнем комментарии задач). И проблема у схемы «по потоку на задачу» совсем не с этим. А с тем, что если наплодить много активных потоков, то эффективность резко уменьшится за счёт накладных расходов на переключение контекста (вот это становится действительно затратная операция с учётом её частоты).

                    Да, и у вас совсем не легковесные потоки (которые coroutine или fiber), а именно пул потоков с очередями задач, Т.к. для легковесных потоков вам потребовался бы ещё какой-то механизм многозадачности (кооперативной как минимум, т.е. что-то типа yield).
                    • 0
                      Возможно не совсем хороший пример. Мы можем написать как угодно. Разные очереди полезны для абстрагирования от подсистем. Например отделить UI в Main Tread от сетевой подсистемы. Сетевую подсистему от системы записи/чтения с диска, и все предыдущие системы от подсистемы обработки изображений (к примеру).
                      • 0
                        Всё равно непонятно что за абстрагирование такое, если пул потоков и так уже выделен в отдельную абстракцию. В общем ни одного примера показывающего хотя бы минимальную полезность нескольких очередей (кроме случая с main ui, который является особым) я так не увидел.

                        Ну и соответственно если остановится на том, что нам не требуется много очередей, то по сути окажется что практически всё уже реализована в стандартной библиотеке.

                        Не реализованным остался только механизм обработки результатов выполнения дополнительных потоков в основном потоке (UI). В смысле в стандарте C++11 этого нет. В C++14 уже планируют добавить, в Boost'e уже сейчас есть. Ну и естественно можно плодить множество своих велосипедиков, как во всех этих последних статьях.
                        • +2
                          в objective-c очереди хороши тем, что можно манипулировать очерёдностью выполнения блоков в этой очереди.
                          Скажем, есть очередь для парсинга файлов, в ней ожидают своей очереди на обработку несколько блоков парсинга. те, что не выполняются можно своевременно отменить или поставить в начало очереди.
                          Это полезно, если хочется выставлять каким то задачам более высокие приоритеты (например, общение с основным потоком).
                          Плюс отделение задач в разные очереди полезны хотя бы для логического разделения. Тут сеть, тут IO, тут UI
        • +5
          Пришел узнать, как распараллелить наибольший общий делитель (greatest common divisor) :)
          • –1
            Как же все-таки обычный Objective C элегантен.

            При всем уважении, надеюсь, что ваш велосипед найдет достойное место среди музейных экспонатов, и я никогда не встречу его в реальных проектах.
            • +1
              Ну, если говорить о Grand Central Dispatch, то он имеет C интерфейс, и оперирует блоками, аналогами которых есть лямбды в C++. Практически ничего от obj-c (кроме блоков) в GCD нет.
              Сравните
              obj-c:
              dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                  // do smth
                  dispatch_async(dispatch_get_main_queue(), ^{
                      // do smth in main thread
                  });
              });

              C++:
              dispatch::queue(dispatch::QUEUE_PRIORITY::DEFAULT).async([]{
                  // do smth
                  dispatch::queue::main_queue()->async([]{
                      // do smth in mai thread
                  });
              });


              Для вас замена "[]" на "^" при объявлении замыкания настолько критична?
              • –2
                Да, первый выглядит гораздо понятнее. Алсо, обычно я не использую стандартные очереди:

                    static dispatch_once_t once;
                    static dispatch_queue_t queue;
                    
                    dispatch_once(&once, ^{
                        queue = dispatch_queue_create("queue name", DISPATCH_QUEUE_SERIAL);
                    });
                
                    dispatch_async(queue, ^{
                        // do smth
                        dispatch_async(dispatch_get_main_queue(), ^{
                             // do smth in main thread
                        });
                    });
                


                блоки сами по себе не совсем чистый си, а полноценный objc объект. ^{} — это синтаксический сахар.

                Стандартный блок можно засовывать, например, в NSDictionary, NSArray. А с вашими блоками возникнет проблема. Да можно засовывать в плюсовые структуры. Но я не хочу париться с этим, когда 70% кода проекта реюзается из собственных библиотек objc, выполненных в предыдущих проектах. Последнее, пожалуй, основная причина, почему я не люблю плюсовые велосипеды.

                На последних трёх проектах, которые пострадали от такого велосипедостроения стало понятно то, что поддерживать такое может только велосипедостроители, причем те самые, что это написали (не другие). Если они не доступны, проект быстрее переписать, чем другому разработчику тратить время на вхождение в код.

                А поскольку программирование — это нечто отличное, чем африканский труд, стараюсь не связываться с плюсовиками в iOS/OSX разработке.

                Сорри, это немного наболевшее.
                • 0
                  Если вы пишете только под iOS/OSX и используете, clang с эпловой стандартной библиотекой с++11, то вы можете и лямбды и блоки хранить в NS-контейнерах:
                  NSArray* array = @[ []{ std::cout << "hello" << std::endl; } ];
                  void(^say_hello)() = [array lastObject];
                  say_hello();
                  А теперь представьте, что вы пишете кроссплатформенный код на чистом С++. Например вы используете cocos2d-x, и хотите чтобы ваш код запускался на iOS, Android, WindowsPhone8, BlackBerry, MeeGo, win32, Linux, Win8 Metro, Mac OS X. Вы там не сможете использовать Objective-C блоки в принципе. Вот тут и появляются велосипеды ))
            • 0
              Не бойтесь велосипедов,, пропеллерами разящих.
              • 0
                > Проверялся на clang++ 5.0

                Простите, что?! Текущая версия clang'а — 3.3.
                • 0
                  Простите, был взволнован.
                  >clang++ --version
                  Apple LLVM version 5.0 (clang-500.1.61) (based on LLVM 3.3svn)
                  Target: x86_64-apple-darwin12.4.0
                  Thread model: posix
                  • 0
                    Ну… так не 5.0, а 3.3. Либо же стоило указать, что Apple Clang использовался.
              • 0
                file_io_queue.async([=]{
                    file_data = get_data_from_file( file_name );
                    parser_queue.async([=]{
                        parsed_data = parse( file_data );
                        main_queue.async([=]{
                            update_ui_with_new_data( parsed_data ) ;
                        });
                    });
                });
                


                Если абстрагироваться от велосипедов и вернуться к реальной разработке, то с помощью Boost.Asio вы можете делать именно то что вы в этом примере привели (почти буквально). Только вместо слова async будет слово post, а вместо queue будет io_service.
                • 0
                  Ну да. Но на то он и велосипед, чтобы попытаться самому реализовать уже реализованное.
                  • 0
                    Просто фраза «Но почти все реализации, виденные мной на просторах интернетиков, предлагают использовать один std::thread для одной очереди. С моей точки зрения это не есть хорошо. » — она предполагает что были исследованы основные реализации.
                    А на деле оказывается что самая распространенная, Boost.Asio, являющаяся практически стандартом дефакто, вообще не рассмотрена. Не удивлюсь если окажется что вообще ничего из мейнстримных библиотек не было исследовано, и та фраза — просто фигура речи.
                  • 0
                    А можете набросать хотя бы в таком виде такой же пример на бусте? Я поглядел на их примеры, сходу не понятно как у них решается вот эта строчка:
                    auto main_queue = queue::main_queue();
                  • 0
                    Пока таращился на последнюю картинку, придумал еще один вариант велосипеда: примерно такая же конструкция, только колеса управляются отдельными педалями. Захотел повернуть — притормозил одно колесо. Как в танке )

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