Lock-free memcache API

    Доброго времени суток, хабражители!
    Этот пост есть краткий конспект многих часов раздумий, каляк на бумаге, набросков кода и, в конце-концов, реально работающего кода в продакшене.
    Наш сайт (и далее — просто сайт) активно использует мемкеш для горячих данных. Код, заполняющий мемкеш, может работать очень долго (0,5 секунд — это долго) и при этом пользовательские запросы успевают запустить ещё сотню процедур обновления. Последстия понятны, однако долго мы просто не могли их заметить на уровне общей нагрузки. Только когда мы увидели всплески времени на обслуживание некоторых запросов (от возросшей нагрузки они ещё и попадали в SLOW_QUERIES_LOG MySQL) — тогда и закипела работа.


    Проблема наглядно


    Рассмотрим подробнее сценарий запроса ключа от мемкеша, при котором ключ «протух» или не был ещё установлен.
    Чтобы понять проблему я нарисовал небольшую диаграмму:

    Workflow запроса данных

    Логикой обновления управляет код, т.е. само приложение знает, если ему вернулось FALSE, то надо регенерировать ключ. Как видно из диаграммы, беда возникает в тот момент, когда данные по первому запросу ещё не успели «приготовиться», а мы уже спрашиваем те же данные.
    Спасибо хабраюзеру evilbloodydemon за точное определение — ситуация называется «Dog pile effect».

    Решение


    Выдвигались многочисленные версии — как этого избежать.
    Сначала мы подумали о системе локов и очереди обновлений. Но этот сценарий жуть какой медленный.
    Потом подумали — если код умеет регенерировать данные — пусть этим и занимается. Надо всего лишь вернуть ему FALSE. И сразу же — переустановить старые данные. Итого: процедура обновления запустится один раз, а данные, которые будут возвращаться в приложение вплоть до конца процесса регенерации — «протухнут» всего лишь на время регенерации.
    Для этого мы в мемкеш должны сложить не только сами данные, но и таймаут и время инвалидации ключа. В реальный мемкеш попадает массив на время вдвое большее (чтобы уж наверняка). В документации сказано, что наибольшее время хранения ключа — 30 дней. Т.е. достаточно положить в «обёртку» данные на 15 дней — 1 секунду для верности. Тоже самое касается и ключей с таймаутом = 0 (т.е. навсегда, пока не вытеснится). Ситуаций, когда данные в мемкеше нужны раз в 15 дней — я не встречал. Если у Вас такое произошло — что-то надо менять.
    Также мы быстро заметили проблему с инкрементом. Пришлось договариваться, что все ключи инкремента заканчиваются на "_inc", например. И при обнаружении такого ключа мы просто достаём нужные данные, которые наинкрементил сам мемкеш. *Эту вилку я удалил ил метода Memcache_Proxy::get().

    Код


    Код документирован там где это надо :) Заранее извиняюсь за простыню кода, но сократить больше не получается.
    class MC
    {
        private static $_proxy;
    
        // Singleton for our class, extended of native Memcache class
        private static function _proxy()
        {
            if (is_null(self::$_proxy) || self::$_proxy->closed) self::$_proxy = new Memcache_Proxy;
            return self::$_proxy;
        }
    
        public static function get($key = '')
        {
            return self::_proxy()->get($key);
        }
    
        public static function set($key = '', $data = NULL, $flag = FALSE, $timeout = 3600)
        {
            return self::_proxy()->set($key, $data, $flag, $timeout);
        }
    
        public static function delete($key = '')
        {
            return self::_proxy()->delete($key);
        }
    
        public static function increment($key = '', $increment = 1)
        {
            return self::_proxy()->increment($key, $increment);
        }
    }
    


    Класс МС нужен для общени с одним экземпляром мемкеша внутри всего кода без необходимости явно объявлять подключение к мемкешу. Оно создастся при первом обращении к нужному методу в этом классе.

    class Memcache_Proxy extends Memcache
    {
        public $closed = false;
    
        public function __construct()
        {
            $this->connect(MEMCACHE_HOST, MEMCACHE_PORT, null);
            $this->closed = false;
        }
    
        function __destruct()
        {
            $this->close();
            $this->closed = true;
        }
    
        /**
         * Mirror for $memcache->get() method
         */
        public function get($key = '')
        {
            if (empty($key)) return FALSE;
    
            $data = parent::get($key);
    
            if ($data !== FALSE && $this->_is_valid_cache($data))
            {
                if (!isset($data['_dc_cache'])) $data['_dc_cache'] = NULL;
                //check lifetime
                if (time() > $data['_dc_life_end'])
                {
                    //expired, save the same for a longer time for other connections
                    $this->set($key, $data['_dc_cache'], FALSE, $data['_dc_cache_time']);
                    return FALSE;
                }
                else
                {
                    //still alive
                    return $data['_dc_cache'];
                }
            }
            return FALSE;
        }
    
        /**
         * Mirror for $memcache->set() method
         */
        public function set($key = '', $data, $flag = FALSE, $timeout = 3600)
        {
            if (empty($key)) return FALSE;
            // Place here "_inc" key check
            if (is_int($data) || $data === FALSE)
                parent::delete($key . '_increment');
    
            // Maximum timeout = 15 days - 1 second
            if ((int)$timeout == 0 || (int)$timeout > 1295999) $timeout = 1295999;
            return $this->_set($key, $data, $flag, $timeout * 2);
        }
    
        /**
         * Mirror for $memcache->delete() method
         */
        public function delete($key = '')
        {
            if (empty($key)) return FALSE;
            // Magic for increment. Place here "_inc" key check
            parent::delete($key . '_increment');
            return parent::delete($key);
        }
    
        public function increment($key, $increment = 1)
        {
            $inc_value = parent::increment($key . '_increment', $increment);
    
            $data = parent::get($key);
            if ($data === FALSE) return FALSE;
    
            if ($this->_is_valid_cache($data))
            {
                if ($inc_value === FALSE)
                {
                    $inc_value = $data['_dc_cache'] + $increment;
                    parent::set($key . '_increment', $inc_value, FALSE, $data['_dc_cache_time'] * 2);
                }
    
                $time = $data['_dc_life_end'] - time();
                if ($time > 0)
                {
                    $this->_set($key, $inc_value, FALSE, $time);
                    return $inc_value;
                }
            }
            return $inc_value;
        }
    
        private function _set($key = '', $data, $flag = FALSE, $timeout = 3600)
        {
            $cache = array('_dc_cache' => $data, '_dc_life_end' => time() + $timeout, '_dc_cache_time' => $timeout);
            return parent::set($key, $cache, $flag, $timeout);
        }
    
        // Maybe we have pure Memcache data, not our array structure
        private function _is_valid_cache($value)
        {
            return (is_array($value) &&
                isset($value['_dc_life_end']) && isset($value['_dc_cache_time']) &&
                !empty($value['_dc_life_end']) && !empty($value['_dc_cache_time'])
            ) ? TRUE : FALSE;
        }
    }
    


    Примеры использования


    Код, просто код. Если данные прокисли, запускаем генерацию возвращая FALSE только запросившему и переустанавливаем те же данные на то же время. Таким образом — следующий запросивший получит старые данные до тех пор, пока первый процесс не закончит генерацию и не выполнит MC::set() с актуальными данными. Сразу же после этого все процессы будут получать актуальные данные.

    $data = MC::get('some_key');
    if ($data === FALSE)
    {
        // Может выполняться очень долго
        $data = huge_generate_func_call();
        MC::set('some_key', $data, FALSE, 3600);
    }
    


    Т.е. продолжаем пользоваться мемкешом как и раньше. Если существовала обёртка для обращения к мемкешу — можно поправить её и ВООБЩЕ ничего в коде приложения не трогать. Это, кстати, было одно из требований: минимальный рефакторинг для внедрения нового класса мемкеша.

    Резюме


    На накладные расходы по хранению таймстампа и таймаута можно закрыть глаза, память нынче дешёвая.
    То, что данные «протухают» на величину времени, равную времени генерации данных — не смертельно и терпимо, однако новые потоки на генерацию одних и тех же данных не создаются. ЧТД!

    P.S.
    Предложения и замечания — приветствуются! Орфография — в личку, по существу — в комментарии!

    UPD.
    Товарищи минусующие — аргументируем свой выбор. Не все родились с талантами Пушкина и Страуструпа!

    UPD. 2
    Разбираемся с минусами:
    1.
    Класс МС нужен для того, чтобы в коде ничего не менять. Совсем. Замените его на имя своей обёртки над мемкешем, если такая есть. Если нет — большинство порядочных IDE поддерживают Refactor -> Change ClassName.
    2.
    Класс МС статический. Так вышло исторически — минимум рефакторинга — основное требование. Переделывать код для Хабра я не стал — основная идея там отражена.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 69

      +3
      Dog pile effect эта ситуация называется.
        +1
        Не знал, спасибо. То что не Race condition — это точно. Думал — как назвать, плюнул и никак не назвал.
          0
          Обновил топик.
          +2
          И вправду, код не очень читабелен.

          Каким способом вы устраняете этот эффект? Не вижу, где инкремент тестируется на то, что не было выполнено одновременное увеличение двумя процессами.
            0
            На всякий случай приведу рабочий код. Не факт, что оптимальный, но работает.

              # Использование
              v = DataCache.get_or_evaluate("key", 10.seconds) do
                5 + 5 # evaluation block
              end 
            
              # берет значение из кэша.
              # возвращает его, если оно актуально, или если новое значение кем-то вычисляется.
              # иначе устанавливает признак вычисления, вычисляет, пишет в кэш и возвращает.
              def get_or_evaluate(key, expiry = 0)
                return yield if @impl.nil?
                key_in_eval = "#{key}:in_eval"
                key_expiry_at = "#{key}:expiry_at"
                @impl.add(key_in_eval, 0, 0, true) rescue nil
                loop do
                  val = get(key)
                  in_eval = ((@impl.get(key_in_eval, true) || 0) rescue 0).to_i
                  expiry_at = ((@impl.get(key_expiry_at, true) || 0) rescue 0).to_i
                  logger.debug("In eval: #{in_eval}, Expiry at: #{expiry_at}, Now: #{Time.now.to_i}")
                  return val if val && (in_eval > 0 || expiry_at >= Time.now.to_i)
                  begin
                    break if @impl.incr(key_in_eval, 1) == 1
                    return val if @impl.decr(key_in_eval, 1) == 1
                  rescue
                    return val
                  end
                  sleep 0.1
                end
                delete(key_expiry_at)
                begin
                  val = yield
                  set(key, val)
                  @impl.set(key_expiry_at, Time.now.to_i + expiry, 0, true)
                ensure
                  @impl.decr(key_in_eval, 1)
                end
                val
              end
            
              0
              Весь инкремент даден на откуп мемкешу. Если в 2-х процессах одновременно произойдёт инкремент — отлично. В нативном ключе ..._increment будет лежать значение, которое наинкрементил мемкеш. А в надстроенном массиве — просто бекап на случай, если кто-то захочет взять значение инкрементируемого ключа через обычный MC::get().
                0
                Разбил код на 2 части. Вторая часть немного больше, но там всего 6 небольших методов.
                Самая большая рутина — в каждом методе распознавать инкрементные ключи и удалять нативный инкрементирующися ключ. значение
                  0
                  Первую часть можно вообще убрать.
                  По методу — способ оригинальный, но всё равно может запуститься несколько процессов на генерацию контента из бд.
                    0
                    Кстати лок можно поставить только на процедуру проверки валидности кеша.
                  +1
                  Подскажите, разъясните нубу, может я чего-то не понимаю, а чем плох (неправилен) такой подход, как ключ-backup для memcache — просто с увеличенным значением времени *жизни* ключа? Т.е. когда время жизни кончилось, отдаём ключ-backup (который старше на 5 секунд, скажем). Соответственно генерируем новые.

                    0
                    как вы будете контролировать, что вычисление идет строго в одном процессе?
                      0
                      По идее, для этого процесс ставит лок и дальше свободно занимается «математикой».
                      0
                      Также, где гарантия что генерация вложится в 5 секунд?
                        +1
                        При этом, количество хранимых данных вырастет в ~2 раза (данные + backup)
                        +1
                        Простите, но, возможно, вы иначе переизобрели CAS.
                          +1
                          Это избавит только от повторного set по ключу, а не от повторных вычислений сохраняемых данных.
                            0
                            Напишите, пожалуйста, конкретный пример использования кода выше.
                              0
                              Это в основную ветку комментарий? Всё равно — добавлю секцию «Примеры использования».
                                0
                                Оу, таки да, ошибся ^_^. Вообщем, это к вам.
                                  0
                                  Добавил
                            0
                            Не совсем так, но за пруфлинк спасибо. В моей реализации CAS заменит incr/decr.
                            0
                            Попробуйте в 10 потоков запустить следующий код на холодном кеше:
                            $data = MC::get('some_key');
                            if ($data === FALSE)
                            {
                            sleep(60);
                            $data = "test string";
                            MC::set('some_key', $data, FALSE, 3600);
                            }

                            Как я заметил из исходников — 10 раз будет выполняться математика, пока кеш не нагреется. В случае релизов с полным обновлением кеша — это равносильно убитой тушке сервера.

                              0
                              За слив кеша у нас бъют по разным частям тела. И без этой логики это будет равносильно его ооочень скрипучей работе.
                              Локи — медленно и надо привязывать к этой надстройке, а не к голому мемкешу. Плюс — в каком виде хранить генераторы данных и организовывать очередь выполнения — тоже вопрос. Иначе сервера лягут, но уже от РНР :)
                                0
                                Сайт-то хоть живой? Можно в личку url? Интересно, что вы там такое монструозное обрабатываете на нескольких серверах.
                                  0
                                  Отправил. Живее всех живых :) Просто огромный траффик + всякие примочки типа счётчиков, статистика и т.д. 0,5 секунд — бесконечно долгое время, за которое не одна сотня запросов может прийти.
                                  +1
                                  Ну и в догонку — как вы обновляете кеш при изменении структуры хранения данных?
                                    0
                                    Сбрасываем нужные ключикию Если данные важны — генерируем данные и просто заменяем.
                                0
                                а для тех кто с php не знаком можете плизз пояснить,
                                вот если 10 потоков запросили данные и их нет в кэше.
                                первый поток кто дорвался ставит флаг что вычисление идет,
                                остальные 9 видя этот флаг ждут типа sleep?
                                когда флаг пропадает, то опять запускается функция получения данных, так?
                                0
                                Это очень и очень приблизительный сценарий. В идеале код должен понять, что есть лок и показать пользователю что-то типа «обновление...» на месте, где должно быть значение из мемкеша.
                                В это время, первый дорвавшийся выставляет лок и обновляет данные. Либо ставит задачу в очередь на обновление и тоже умирает.
                                  +1
                                  Советую использовать Redis + команду WATCH.
                                    0
                                    Это уже в ToDo листе. Redis или Mongo — нужен будет файловый кеш, чтобы при падении кеш-сервера/гибели мира/Чаке в датацентре и т.д. случаях кеш поднимался из файла сначала, а потом уже дёргал приложение.
                                      0
                                      Так Redis и как низколатентный кэш подходит. При наличии append-only режима и bgrewriteaof он вполне себе заменяет тот же персистентный memcacheDB. А ставите maxmemory-policy allkeys-lru — и у вас получается чистейшей воды memcached, только гораздо функциональнее.
                                    0
                                    habrahabr.ru/blogs/webdev/43540/
                                    30 сентября 2008 года.
                                      0
                                      Я тогда ещё не знал про Хабр :)
                                      Да и у меня пример кода имеется.
                                      +1
                                      очень похожая тема goo.gl/dStVz
                                        0
                                        а знаем ли мы про магию CAS?
                                          0
                                          Магия CAS тут совершенно ни при чём.
                                          Как справедливо заметил Darivush тут:
                                          Это избавит только от повторного set по ключу, а не от повторных вычислений сохраняемых данных.
                                          0
                                          Хм, а если перед генерацией данных ставить этот ключ в определенное значение например «in_progress» и проверять при выборке на это значение, если получили это значение значит зацикливаем проверку с шагом в определенное время перепроверяем? Хотя что-то не айс вариант чувствую :)
                                            0
                                            2 (10, 100, ...) процессов одновременно поставят in_progress и начнут генерировать данные одновременно.
                                              0
                                              Хм, ну тогда было бы здорово если мемкэш умел бы сам блочить для остальных процессов запись и чтение по этому ключу до тех пор пока процесс, обнаруживший протухшее значение, не закончит регенерацию данных :)
                                                0
                                                karellen привёл толковую альтернативу:
                                                Советую использовать Redis + команду WATCH.
                                                  0
                                                  Еще из адекватных альтернатив вижу: не устанавливать expires время, а делать все данные бесконечно живущими, а при поступлении инфы, которая должна обновить данные, просто обновлять кэш, причем так, что бы если один процесс, который и принял инфу (он же ее и будет обновлять в кэше), не блокировал доступ к кэшу, а оставлял там старые данные чтоб остальные их могли получить беспрепятственно %) Вобщем нужно что-то вроде транзакций реализовать, тогда воркеры будут получать старые данные до тех пор пока один из них не обновит всю нужную инфу в кэше по требованию так сказать, причем обновить надо сразу и все что затронется, а не постепенно, целостность не нарушится так %)
                                                    0
                                                    А два процесса, одновременно обновляющие данные под одним ключом? :) Вроде механизм типа транзакций только внешними блокировками с memcache можно реализовать.
                                                      0
                                                      Охохо, ежели ничего не удалять — ужасающе быстро закончится место. В моём коде так и делается: один начинает генерацию, а остальные — «кто не успел :)» — получают старые данные.
                                                    0
                                                    Хотя, наверное, можно намутить что-то вроде
                                                    while(1) {
                                                      $res = $mc->add($key . '_in_progress', getmypid());
                                                      if ($res !== FALSE) {
                                                        $value = someHardFunction(); // TODO: DI
                                                        $mc->set($key, $value);
                                                        $mc->delete($key . '_in_progress');
                                                        return $value;
                                                      }
                                                      while($mc->get($key . '_in_progress') !== FALSE) {
                                                        usleep(100);
                                                      }
                                                      $value = $mc->get($key);
                                                      if ($value !== FALSE) {
                                                        return $value;
                                                      }
                                                    }
                                                    

                                                    Но, имхо, во-первых, гарантий не даёт, что только один процесс будет обрабатывать ($key. '_in_progress' может удалиться, пока обработка ещё не завершена), во-вторых, не надежно (ситуация «ключ уже существует», «ключ не найден» и, например, «сервер не отвечает» Memcache не позволяет различить вроде как), в-третьих, если someHardFunction() зависнет, то все остальные будут ждать (это кодом можно решить, в принципе). Да и вообще как-то некрасиво :)
                                                      0
                                                      Совсем не хочется иметь N висящих потоков. Не к добру это.
                                                      P.S. цитата коммента ниже
                                                        0
                                                        Ну тогда не ожидать удаления $key. '_in_progress', если не получилось его добавить, а возвращать ошибку. Для веба 409-ю (прежде всего для POST, PUT, DELETE) с просьбой повторить запрос позже. Или (для GET-запросов) возвращать текущее состояние кэша со статусом 203.

                                                        Или на асинхронное программирование переходить — вроде для него это типичная задача :)
                                                    0
                                                    > 2 (10, 100, ...) процессов одновременно поставят in_progress и начнут генерировать данные одновременно.

                                                    не поставят.
                                                    какой первый поток доберется, тот и успеет поставить флаг.
                                                    остальные его не получат и просто будут в sleep и проверять каждые N мс,
                                                    как только данные сгенерятся, то флаг первый поток снимет, и остальные тут же получат все данные.
                                                  0
                                                  и с шагом*
                                                    +1
                                                    Совсем не хочется иметь N висящих потоков. Не к добру это.
                                                      0
                                                      Да, но они хотя бы могут сидеть в sleep не нагружая ни процессоры ни базу.
                                                      Альтернатива — отдать ошибку, но лучше ли это?
                                                        0
                                                        Они будут жрать оперативу. Так что для высоких нагрузок не очень вариант…
                                                          0
                                                          Если конфликт скорее исключение, чем правило, да ещё клиент не тупой браузер, а свой (JS-приложение в тупом браузере :), например), который через некоторое время запрос повторит без вмешательства пользователя, то лучше, имхо, ошибку отдать.
                                                            0
                                                            300 МБ исходников, часть которых завязана на мемкеш и логику работы с ним. Не получится без конфликтов никак — это нормальное поведение. Запрос — нет данных — генерация — в промежутке ещё 100 запросов. Для того и делалось :)
                                                              0
                                                              А собственно как поведёт себя приложение, когда сразу после старта получит 100 одинаковых запросов? Первый начнёт генерацию, а остальные 99 что вернут? Данных ещё нет же.

                                                              P.S.
                                                               return (is_array($value) &&
                                                                          isset($value['_dc_life_end']) && isset($value['_dc_cache_time']) &&
                                                                          !empty($value['_dc_life_end']) && !empty($value['_dc_cache_time'])
                                                                      ) ? TRUE : FALSE;
                                                              

                                                              не слишком избыточно?
                                                               return !empty($value['_dc_life_end']) &&  !empty($value['_dc_cache_time'];
                                                              

                                                              не достаточно будет? Или это отточенная на реальных данных оптимизация?
                                                                0
                                                                1. Сценарий запуска пока кеш не разогреется — апокалиптичен по определению. У нас бъют сильно-сильно по всяким выступающим частям тела за слив мемкеша (либо админа за рестарт и т.п. — за что что недоглядел).
                                                                2. Максимальная оптимизация, которую тут можно провести:
                                                                return (is_array($value) &&
                                                                    isset($value['_dc_life_end']) && isset($value['_dc_cache_time']) &&
                                                                    !empty($value['_dc_life_end']) && !empty($value['_dc_cache_time'])
                                                                );
                                                                

                                                                Ибо должна произвестись ошибка уровня E_NOTICE при использовании одних только empty(). Даже если они по-умолчанию отключены — не значит что их нет :) Да и isset() и empty() — функции, предназначенные совершенно для разных вещей и какбе логично сначала проверить существование элемента массива (если нет — ветка просто обломается и дальше считаться не будет), а потом уже — его пустоту.
                                                                  0
                                                                  Упс, забыл, что нотайсы по дефолту выключены, когда тестил — недавно систему переставлял, php.ini ещё не трогал. Без is_array() нотайсы даст и isset()?
                                                                    0
                                                                    Даст, но не во всех случаях.
                                                                    1. НЕ даст при обращении к строке как к массиву, если индекс меньше длины строки или индекс не цифровой. Изучите вывод, но не забудьте включить E_NOTICE.
                                                                    error_reporting(2147483647);
                                                                    $a = 'qwerty';
                                                                    var_dump(isset($a[2]), $a[2], isset($a[9]), $a[9], isset($a['some_key']), $a['some_key']);
                                                                    

                                                                    2. НЕ даст при обращении к объекту, который реализует ArrayAccess интерфейс
                                                                    3. НЕ даст при обращении к числу (в т.ч. и флоату) как к массиву
                                                                    error_reporting(2147483647);
                                                                    $a = 5.7;
                                                                    var_dump(isset($a[2]), $a[2], isset($a[9]), $a[9], isset($a['some_key']), $a['some_key']);
                                                                    

                                                                    4. НЕ даст при обращении к булевой переменной как к массиву. (Пример поменять самому);
                                                                    5. ДАСТ ошибку уровня E_USER_ERROR (Fatal в простонародье) обращение к объекту, который не реализует интерфейс ArrayAccess. В пример добавить
                                                                    $a = new stdClass;
                                                                    


                                                                    Фууух
                                                                      0
                                                                      6. ДАСТ такую же ошибку как в п. 5 при касте массива в объект
                                                                      $a = array('a' => 111);
                                                                      $a = (object)$a;
                                                                      var_dump(isset($a[0]), $a[0], isset($a[9]), $a[9], isset($a['some_key']), $a['some_key']);
                                                                      
                                                          0
                                                          Промах.
                                                          Совсем не хочется иметь N висящих потоков. Не к добру это.
                                                            +1
                                                            Бляяя, извините. Не посчитал точечки — монитор большой.
                                                          +1
                                                          > В документации сказано, что наибольшее время хранения ключа — 30 дней.

                                                          Это не совсем верно. Можно заставить данные жить и дольше, если передавать не время жизни, а unix timestamp момента, когда они должны протухнуть.
                                                            0
                                                            Смирнов еще в 2008 изложил отличный подход к этому велосипеду. "Кэширование и memcached".
                                                              0
                                                              Почитал статью по ссылке. Ничего сверхъестественного не нашёл. Почитал вдумчиво. Опять не нашёл. Ткните носом в отличные подходы пожалуйста.
                                                                0
                                                                Именно, что их как раз и нет. Я к тому и веду, что все уже описанно до нас как минимум с 2008 года.
                                                                  0
                                                                  И да — с 2008 года ничего в софтверном мире не появилось.
                                                                  Я предложил конкретное решение конкретной проблемы. Решение работающее.

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

                                                            Самое читаемое