Как стать автором
Обновить

Реализация синглтона в многопоточном приложении

Время на прочтение14 мин
Количество просмотров16K


Введение


В настоящий момент сложно себе представить программное обеспечение, работающее в одном потоке. Конечно, существует ряд простых задач, для которых один поток более, чем достаточен. Однако так бывает далеко не всегда и большинство задач средней или высокой сложности так или иначе используют многопоточность. В этой статье я буду говорить об использовании синглтонов в многопоточной среде. Несмотря на кажущуюся простоту эта тема содержит множество нюансов и интересных вопросов, поэтому считаю, что она заслуживает отдельной статьи. Здесь не будет затрагиваться обсуждение того, зачем использовать синглтоны, а также как их правильно использовать. Для прояснения этих вопросов я рекомендую обратиться к моим предыдущим статьям, посвященным разным вопросам, связанным с синглтонами [1], [2], [3]. В этой статье речь будет идти о влиянии многопоточности на реализацию синглтонов и обсуждению вопросов, которые всплывают при разработке.

Постановка задачи


В предыдущих статьях была рассмотрена следующая реализация синглтона:

template<typename T>
T& single()
{
    static T t;
    return t;
}

Идея данной функции достаточно проста и незамысловата: для любого типа T мы можем создать экземпляр этого типа по требованию, т.е. «лениво», причем количество экземпляров, созданных этой функцией, не превышает значения 1. Если экземпляр нам не нужен, то проблем с точки зрения многопоточности (да и с точки зрения времени жизни и других проблем) вообще нет. Однако что произойдет, если в нашем многопоточном приложении одновременно 2 или более потоков захотят вызвать эту функцию с одним и тем же типом T?

Стандарт C++


Перед тем, как ответить на этот вопрос с практической точки зрения, предлагаю ознакомиться с теоретическим аспектом, т.е. ознакомимся со стандартом C++. На данный момент компиляторами поддерживается 2 стандарта: 2003 года и 2011 года.

$6.7.4, C++03

The zero-initialization (8.5) of all local objects with static storage duration (3.7.1) is performed before any other initialization takes place. A local object of POD type (3.9) with static storage duration initialized with constant-expressions is initialized before its block is first entered. An implementation is permitted to perform early initialization of other local objects with static storage duration under the same conditions that an implementation is permitted to statically initialize an object with static storage duration in namespace scope (3.6.2). Otherwise such an object is initialized the first time control passes through its declaration; such an object is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control re-enters the declaration (recursively) while the object is being initialized, the behavior is undefined.

$6.7.4, C++11

The zero-initialization (8.5) of all block-scope variables with static storage duration (3.7.1) or thread storage duration (3.7.2) is performed before any other initialization takes place. Constant initialization (3.6.2) of a block-scope entity with static storage duration, if applicable, is performed before its block is first entered. An implementation is permitted to perform early initialization of other block-scope variables with static or thread storage duration under the same conditions that an implementation is permitted to statically initialize a variable with static or thread storage duration in namespace scope (3.6.2). Otherwise such a variable is initialized the first time control passes through its declaration; such a variable is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization(*). If control re-enters the declaration recursively while the variable is being initialized, the behavior is undefined.

(*) The implementation must not introduce any deadlock around execution of the initializer
(выделено мной)

Если вкратце, то новый стандарт говорит о том, что если во время инициализации переменной (т.е. создания экземпляра) второй поток пытается получить доступ к этой же переменной, то он (поток) должен ожидать завершения инициализации, при этом реализация не должна допускать ситуации deadlock. В более раннем стандарте о многопоточности, как можно убедиться, не сказано ни слова.

Остается теперь выяснить, какие компиляторы действительно поддерживают новый стандарт, а какие лишь пытаются делать вид, что поддерживают. Для этого проведем следующий эксперимент.

Эксперимент


При использовании многопоточных примитивов я буду использовать фреймворк Ultimate++. Он достаточно легковесный и простой в использовании. В рамках данной статьи это не играет принципиального значения (можно, например, использовать boost).

Для нашего эксперимента напишем класс, создание которого занимает достаточно продолжительное время:

struct A
{
	A()
	{
		Cout() << '{';      // маркер начала создания класса
		Thread::Sleep(10);  // ожидание 10 мс
		if (++ x != 1)
			Cout() << '2';  // маркер ошибки: повторная инициализация объекта
		Cout() << '}';      // окончание создания класса
	}

	~A()
	{
		Cout() << '~';      // уничтожение класса
	}

	int x;
};

В начальный момент создания класса значение x равно 0, т.к. мы планируем его использовать только из синглтона, т.е. со словом static, при использовании которого все POD-типы будут проинициализированы значением 0. Затем мы ожидаем некоторое время, эмулируя длительность операции. В конце идет проверка на ожидаемое значение, если оно отличается от единицы, то выдаем сообщение об ошибке. Здесь я использовал вывод символов для того, чтобы более наглядно показать последовательность операций. Я специально не использовал сообщения, т.к. для этого потребовалась бы дополнительная синхронизация при многопоточном использовании, чего хотелось избежать.

Далее напишем функцию, вызываемую при создании новых потоков:

void threadFunction(int i)
{
	Cout() << char('a'+i);  // начало функции - маленькая буква английского алфавита, начиная с первой 
	A& a = single<A>();     // вызов нашего синглтона
	if (a.x == 0)
		Cout() << '0';      // маркер ошибки: синглтон не проинициализирован
	Cout() << char('A'+i);  // окончание функции - соответствующая большая буква
}

И будем вызывать функцию threadFunction одновременно из 5 потоков, тем самым эмулируя ситуацию, когда происходит конкурентный доступ к синглтону:

for (int i = 0; i < 5; ++ i)
	Thread::Start(callback1(threadFunction, i));
Thread::ShutdownThreads();

Для проведения эксперимента я выбрал 2 достаточно популярных на сегодня компилятора: MSVC 2010 и GCC 4.5. Также проводилось тестирование с использованием компилятора MSVC 2012, результат полностью соответствовал версии 2010, поэтому в дальнейшем упоминание про него я опущу.

Результат запуска для GCC:
ab{cde}ABCDE~

Результат запуска для MSVC:
ab{0cB0dCe00DE}A~

Обсуждение результатов эксперимента


Обсудим полученные результаты. Для GCC происходит следующее:
  1. запуск функции threadFunction для потока 1
  2. запуск функции threadFunction для потока 2
  3. начало инициализации синглтона
  4. запуск функции threadFunction для потока 3
  5. запуск функции threadFunction для потока 4
  6. запуск функции threadFunction для потока 5
  7. завершение инициализации синглтона
  8. выход из функции threadFunction последовательно для всех потоков 1-5
  9. завершение программы и уничтожение синглтона

Здесь не происходит каких-либо неожиданностей: синглтон инициализируется только один раз и функция threadFunction завершает свою работу только после завершения инициализации синглтона => GCC корректно инициализирует объект в многопоточном окружении.

Ситуация с MSVC несколько иная:
  1. запуск функции threadFunction для потока 1
  2. запуск функции threadFunction для потока 2
  3. начало инициализации синглтона
  4. ошибка: синглтон не проинициализирован
  5. запуск функции threadFunction для потока 3
  6. выход из функции threadFunction для потока 2
  7. ошибка: синглтон не проинициализирован
  8. ...
  9. выход из функции threadFunction для потока 5
  10. завершение инициализации синглтона
  11. выход из функции threadFunction для потока 1
  12. завершение программы и уничтожение синглтона

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

Анализ результатов эксперимента


Попытаемся разобраться, чем отличается результат, полученный рассмотренными компиляторами. Для этого скомпилируем и дизассемблируем код:

GCC:
5       T& single()
   0x00418ad8 <+0>:     push   %ebp
   0x00418ad9 <+1>:     mov    %esp,%ebp
   0x00418adb <+3>:     sub    $0x28,%esp

6       {
7           static T t;
   0x00418ade <+6>:     cmpb   $0x0,0x48e070
   0x00418ae5 <+13>:    je     0x418af0 <single<A>()+24>
   0x00418ae7 <+15>:    mov    $0x49b780,%eax
   0x00418aec <+20>:    leave
   0x00418aed <+21>:    ret

   0x00418af0 <+24>:    movl   $0x48e070,(%esp)
   0x00418af7 <+31>:    call   0x485470 <__cxa_guard_acquire>
   0x00418afc <+36>:    test   %eax,%eax
   0x00418afe <+38>:    je     0x418ae7 <single<A>()+15>
   0x00418b00 <+40>:    movl   $0x49b780,(%esp)
   0x00418b07 <+47>:    call   0x4195d8 <A::A()>
   0x00418b0c <+52>:    movl   $0x48e070,(%esp)
   0x00418b13 <+59>:    call   0x4855cc <__cxa_guard_release>
   0x00418b18 <+64>:    movl   $0x485f04,(%esp)
   0x00418b1f <+71>:    call   0x401000 <atexit>

8           return t;
9       }
   0x00418b24 <+76>:    mov    $0x49b780,%eax
   0x00418b29 <+81>:    leave
   0x00418b2a <+82>:    ret

Видно, что перед тем, как вызвать конструктор объекта A, компилятор вставляет вызов функций синхронизации: __cxa_guard_acquire/__cxa_guard_release, что позволяет безопасно запускать функцию single одновременно при инициализации синглтона.

MSVC:
T& single()
{
00E51420  mov         eax,dword ptr fs:[00000000h]  
00E51426  push        0FFFFFFFFh  
00E51428  push        offset __ehhandler$??$single@UA@@@@YAAAUA@@XZ (0EE128Eh)  
00E5142D  push        eax  
    static T t;
00E5142E  mov         eax,1  
00E51433  mov         dword ptr fs:[0],esp  
; проверка инициализации
00E5143A  test        byte ptr [`single<A>'::`2'::`local static guard' (0F23944h)],al  
00E51440  jne         single<A>+47h (0E51467h)  
; обновление флага на значение "инициализирован"
00E51442  or          dword ptr [`single<A>'::`2'::`local static guard' (0F23944h)],eax  
00E51448  mov         ecx,offset t (0F23940h)  
00E5144D  mov         dword ptr [esp+8],0  
; вызов конструктора: создание объекта
00E51455  call        A::A (0E51055h)  
00E5145A  push        offset `single<A>'::`2'::`dynamic atexit destructor for 't'' (0EED390h)  
00E5145F  call        atexit (0EA0AD1h)  
00E51464  add         esp,4  
    return t;
}
00E51467  mov         ecx,dword ptr [esp]  
00E5146A  mov         eax,offset t (0F23940h)  
00E5146F  mov         dword ptr fs:[0],ecx  
00E51476  add         esp,0Ch  
00E51479  ret  

Здесь компилятор использует переменную по адресу 0x0F23944 для проверки инициализации. Если объект не был до сих пор инициализирован, то это значение устанавливается в единицу, а затем без затей вызывается инициализация синглтона. Видно, что никаких синхронизаций не предусмотрено, что и объясняет результат, полученный в результате нашего эксперимента.

Простое решение


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

// класс для автоматической работы с глобальным мьютексом
struct StaticLock : Mutex::Lock
{
	StaticLock() : Mutex::Lock(mutex)
	{
		Cout() << '+';
	}

	~StaticLock()
	{
		Cout() << '-';
	}
	
private:
	static Mutex mutex;
};

Mutex StaticLock::mutex;

template<typename T>
T& single()
{
	StaticLock lock;  // сначала вызываем mutex.lock()
	static T t;       // инициализируем синглтон
	return t;         // вызываем mutex.unlock() и возвращаем результат
}

Результат запуска:

ab+{cde}-A+-B+-C+-D+-E~

Последовательность операций:
  1. запуск функции threadFunction для потока 1
  2. запуск функции threadFunction для потока 2
  3. взятие глобальной блокировки: mutex.lock()
  4. начало инициализации синглтона
  5. запуск функции threadFunction для потока 3
  6. запуск функции threadFunction для потока 4
  7. запуск функции threadFunction для потока 5
  8. завершение инициализации синглтона
  9. снятие глобальной блокировки: mutex.unlock()
  10. выход из функции threadFunction для потока 1
  11. взятие глобальной блокировки: mutex.lock()
  12. снятие глобальной блокировки: mutex.unlock()
  13. выход из функции threadFunction для потока 2
  14. ...
  15. выход из функции threadFunction для потока 5
  16. завершение программы и уничтожение синглтона

Такая реализация полностью избавляет от проблемы возвращения неинициализированного объекта: перед началом инициализации вызывается mutex.lock(), а после завершения инициализации вызывается mutex.unlock(). Остальные потоки ожидают завершения инициализации перед тем, как начать его использовать. Однако, у такого подхода есть существенный минус: блокировка используется всегда, вне зависимости от того, проинициализирован ли уже объект или нет. Для повышения производительности хотелось бы, чтобы синхронизация использовалась только в то время, когда мы хотим получить доступ к объекту, который еще не был проинициализирован (как это реализовано для GCC).

Double-checked locking pattern


Для реализации приведенной выше идеи часто используется подход, который носит название Double-checked locking pattern (DCLP) или шаблон проектирования «блокировка с двойной проверкой». Суть его описывается следующим набором действий:
  1. проверка условия: проинициализирован или нет? Если да — то сразу возвращаем ссылку на объект
  2. берем блокировку
  3. проверяем условие второй раз, если проинициализирован — то снимаем блокировку и возвращаем ссылку
  4. проводим инициализацию синглтона
  5. меняем условие на «проинициализирован»
  6. снимаем блокировку и возвращаем ссылку

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

DCLP можно проиллюстрировать следующим примером:

template<typename T>
T& single()
{
	static T* pt;
	if (pt == 0)      // первая проверка, вне мьютекса
	{
		StaticLock lock;
		if (pt == 0)  // вторая проверка, под мьютексом
			pt = new T;
	}
	return *pt;
}

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

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

Вторая более серьезная проблема состоит в следующей строчке:

pt = new T;

Рассмотрим это поподробнее. Данную строчку можно переписать следующим образом (я опущу обработку исключений для краткости):

pt = operator new(sizeof(T)); // выделяем память под объект
new (pt) T;                   // placement new: вызов конструктора на уже выделенной памяти

Т.е. сначала выделяется память, а затем происходит инициализация объекта вызовом его конструктора. Так вот, может оказаться так, что память уже выделилась, значение pt обновилось, а объект еще не создался. Таким образом если какой-либо поток исполнит первую проверку вне блокировки, то функция single вернет ссылку на память, которая была выделена но не проинициализирована.

Попробуем теперь исправить обе проблемы, описанные выше.

Предлагаемый подход


Введем 2 функции для создания синглтона: одну будем использовать так, как будто у нас однопоточное приложение, а другую — для многопоточного использования:

// небезопасная функция в многопоточном окружении
template<typename T>
T& singleUnsafe()
{
    static T t;
    return t;
}

// функция для использования в многопоточном окружении
template<typename T>
T& single()
{
	static T* volatile pt;
	if (pt == 0)
	{
		T* tmp;
		{
			StaticLock lock;
			tmp = &singleUnsafe<T>();
		}
		pt = tmp;
	}
	return *pt;
}

Идея состоит в следующем. Мы знаем, что наша первоначальная реализация (теперь это функция singleUnsafe) отлично работает в однопоточном приложении. Поэтому, все, что нам необходимо — это сериализация вызовов, которая достигается правильным использованием блокировок. В каком-то смысле здесь происходит тоже 2 проверки, только первая проверка вне блокировки использует указатель, а вторая — внутреннюю переменную, которая сгенерирована компилятором. Здесь также используется ключевое слово volatile для предотвращения переупорядочивания операций в случае чрезмерной оптимизации компилятором. Также стоит отметить, что присваивание указателя pt происходит вне блокировок. Это сделано для того, чтобы не происходило переупорядочивание операций процессором во время исполнения кода.

Результат компиляции такой реализации приведен ниже:

template<typename T>
T& single()
{
; обработка исключений
00083B30  push        0FFFFFFFFh  
00083B32  push        offset __ehhandler$??$single@UA@@@@YAAAUA@@XZ (0A13B6h)  
00083B37  mov         eax,dword ptr fs:[00000000h]  
00083B3D  push        eax  
00083B3E  mov         dword ptr fs:[0],esp  
00083B45  push        ecx  
	static T* volatile pt;
	if (pt == 0)
; первая проверка вне блокировки
00083B46  mov         eax,dword ptr [pt (0E3950h)]  
00083B4B  test        eax,eax  
00083B4D  jne         single<A>+7Dh (83BADh)  
	{
		T* tmp;
		{
			StaticLock lock;
; вызов функции EnterCriticalSection для взятия блокировки
00083B4F  push        offset staticMutex (0E3954h)  
00083B54  mov         dword ptr [esp+4],offset staticMutex (0E3954h)  
00083B5C  call        dword ptr [__imp__EnterCriticalSection@4 (0ED6A4h)]  
			tmp = &singleUnsafe<T>();
00083B62  mov         eax,1  
00083B67  mov         dword ptr [esp+0Ch],0  
; вторая проверка
00083B6F  test        byte ptr [`singleUnsafe<A>'::`2'::`local static guard' (0E394Ch)],al  
00083B75  jne         single<A>+68h (83B98h)  
00083B77  or          dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (0E394Ch)],eax  
00083B7D  mov         ecx,offset t (0E3948h)  
00083B82  mov         byte ptr [esp+0Ch],al  
; инициализация объекта: вызов конструктора
00083B86  call        A::A (1105Fh)  
00083B8B  push        offset `singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (0AD4D0h)  
00083B90  call        atexit (60BB1h)  
00083B95  add         esp,4  
		}
; вызов функции LeaveCriticalSection для снятия блокировки
00083B98  push        offset staticMutex (0E3954h)  
00083B9D  call        dword ptr [__imp__LeaveCriticalSection@4 (0ED6ACh)]  
		pt = tmp;
; запись указателя в pt после всех блокировок
00083BA3  mov         dword ptr [pt (0E3950h)],offset t (0E3948h)  
	}
	return *pt;
}
00083BAD  mov         ecx,dword ptr [esp+4]  
; возвращение результата в регистре eax
00083BB1  mov         eax,dword ptr [pt (0E3950h)]  
00083BB6  mov         dword ptr fs:[0],ecx  
00083BBD  add         esp,10h  
00083BC0  ret  

Я добавил комментарии к ассемблерному коду, чтобы было понятно, что происходит. Интересно отметить код обработки исключений: довольно внушительный кусок. Можно сравнить с кодом GCC, где используются таблицы при раскрутке стека с нулевыми накладными расходами при отсутствии сгенерированного исключения. Если же посмотреть код для платформы x64 компилятора MSVC, то можно увидеть несколько иной подход к обработке исключений:

template<typename T>
T& single()
{
000000013F401600  push        rdi  
000000013F401602  sub         rsp,30h  
000000013F401606  mov         qword ptr [rsp+20h],0FFFFFFFFFFFFFFFEh  
000000013F40160F  mov         qword ptr [rsp+48h],rbx  
	static T* volatile pt;
	if (pt == 0)
000000013F401614  mov         rax,qword ptr [pt (13F4F5890h)]  
000000013F40161B  test        rax,rax  
000000013F40161E  jne         single<A>+75h (13F401675h)  
	{
		T* tmp;
		{
			StaticLock lock;
000000013F401620  lea         rbx,[staticMutex (13F4F58A0h)]  
000000013F401627  mov         qword ptr [lock],rbx  
000000013F40162C  mov         rcx,rbx  
000000013F40162F  call        qword ptr [__imp_EnterCriticalSection (13F50CCC0h)]  
; nop !!!
000000013F401635  nop  
			tmp = &singleUnsafe<T>();
000000013F401636  mov         eax,dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F4F588Ch)]  
000000013F40163C  lea         rdi,[t (13F4F5888h)]  
000000013F401643  test        al,1  
000000013F401645  jne         single<A>+65h (13F401665h)  
000000013F401647  or          eax,1  
000000013F40164A  mov         dword ptr [`singleUnsafe<A>'::`2'::`local static guard' (13F4F588Ch)],eax  
000000013F401650  mov         rcx,rdi  
000000013F401653  call        A::A (13F401087h)  
000000013F401658  lea         rcx,[`singleUnsafe<A>'::`2'::`dynamic atexit destructor for 't'' (13F4A6FF0h)]  
000000013F40165F  call        atexit (13F456664h)  
; nop !!!
000000013F401664  nop  
		}
000000013F401665  mov         rcx,rbx  
000000013F401668  call        qword ptr [__imp_LeaveCriticalSection (13F50CCD0h)]  
		pt = tmp;
000000013F40166E  mov         qword ptr [pt (13F4F5890h)],rdi  
	}
	return *pt;
000000013F401675  mov         rax,qword ptr [pt (13F4F5890h)]  
}
000000013F40167C  mov         rbx,qword ptr [rsp+48h]  
000000013F401681  add         rsp,30h  
000000013F401685  pop         rdi  
000000013F401686  ret  

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

Выводы


Итак, пора сформулировать выводы. В статье показано, что разные компиляторы по разному относятся к новому стандарту: GCC всячески старается адаптироваться к современным реалиям и корректно обрабатывает инициализацию синглтонов в многопоточной среде; MSVC слегка отстает, поэтому требуется аккуратная реализация синглтона, описанная в статье. Приведенный подход представляет собой универсальную и эффективную реализацию без серьезных накладных расходов на синхронизацию.

P.S.


Данная статья — введение в вопросы многопоточности. Она решает проблему доступа в случае создания объекта-синглтона. При дальнейшем использовании его данных возникают другие серьезные вопросы, которые будут подробно рассмотрены в следующей статье.

Update


Подправлена реализация синглтона с учетом комментариев к статье.

Литература


[1] Хабрахабр: Использование паттерна синглтон
[2] Хабрахабр: Синглтон и время жизни объекта
[3] Хабрахабр: Обращение зависимостей и порождающие шаблоны проектирования
[4] Final Committee Draft (FCD) of the C++0x standard
[5] C++ Standard — ANSI ISO IEC 14882 2003
[6] Ultimate++: C++ cross-platform rapid application development framework
[7] Boost C++ libraries
[8] Wikipedia: Double-checked locking
[9] Знакомьтесь, антипаттерн double-checked locking
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+49
Комментарии64

Публикации

Истории

Работа

QT разработчик
7 вакансий
Программист C++
129 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн