суббота, 19 марта 2016 г.

[prog.c++] Голые нити и exception safety

Раз уж на неделе затронул вопрос осторожного обращения с голыми нитями, то можно осветить еще один момент. Касающийся обеспечения exception safety при работе с многопоточностью.

В качестве демонстрации буду использовать вот такую небольшую программку:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

using namespace std;

enum class command
{
   wait,
   calculate,
   close
};

struct context
{
   mutex m_lock;
   condition_variable m_cond;
   command m_command;
};

void worker( context & ctx )
{
   command cmd = command::wait;
   do
   {
      {
         unique_lock< mutex > lock{ ctx.m_lock };
         if( command::wait == ctx.m_command )
            ctx.m_cond.wait( lock, [&ctx] { return command::wait != ctx.m_command; } );
         cmd = ctx.m_command;
         ctx.m_command = command::wait;
      }
   }
   while( cmd != command::close );

   cout << "worker closed" << endl;
}

void demo()
{
   context first_ctx;
   thread first_thread{ worker, ref(first_ctx) };

   {
      lock_guard< mutex > lock{ first_ctx.m_lock };
      first_ctx.m_command = command::close;
      first_ctx.m_cond.notify_one();
   }

   first_thread.join();
}

int main()
{
   try
   {
      demo();
   }
   catchconst exception & x )
   {
      cerr << "Oops: " << x.what() << endl;
   }
}

Здесь функция demo создает рабочий поток, в который предполагается выдавать некоторые команды. Рабочий поток должен ждать получения очередной команды, выполнять ее, ждать следующей команды и т.д. Рабочий поток завершает свою работу когда получает команду command::close. Для взаимодействия потоков используется стандартная пара из mutex-а и condition_variable. Пока команды для рабочего потока нет, он спит на условной переменной.

Функции worker() и demo() не выполняют никакой полезной работы. Это просто "рыба", заготовка, которая демонстрирует общий принцип. Поэтому demo() просто стартует рабочий поток, затем просто отдает ему команду command::close, дожидается завершения рабочего потока и возвращает управление функции main().

Теперь представим себе, что мы начали наполнять demo() реальными операциями. Т.к. исключения у нас не запрещены, то нужно быть готовым к тому, что где-то после старта рабочего потока какое-то исключение у нас выскочит. Готовы ли мы к этому? Проверим, добавив в код явное выбрасывание исключения:

void demo()
{
   context first_ctx;
   thread first_thread{ worker, ref(first_ctx) };

   throw runtime_error{ "something wrong!" };

   {
      lock_guard< mutex > lock{ first_ctx.m_lock };
      first_ctx.m_command = command::close;
      first_ctx.m_cond.notify_one();
   }

   first_thread.join();
}

Что мы получим при запуске? Крах приложения. Поскольку до first_worker.join() дело не дойдет.

Казалось бы, в чем проблема? Напишем RAII-обертку, которая будет для нас вызывать join для запущенных нами потоков:

class auto_joiner
{
   thread & m_what;
public :
   auto_joiner( thread & what ) : m_what{ what } {}
   ~auto_joiner() { m_what.join(); }
};
...
void demo()
{
   context first_ctx;
   thread first_thread{ worker, ref(first_ctx) };
   auto_joiner first_joiner{ first_thread };

   throw runtime_error{ "something wrong!" };

   {
      lock_guard< mutex > lock{ first_ctx.m_lock };
      first_ctx.m_command = command::close;
      first_ctx.m_cond.notify_one();
   }
}

Запускаем и что получим? Зависание!

Это потому, что сигнала на завершение нашей рабочей нити дано не было. И рабочая нить продолжает висеть на условной переменной. А функция demo() висит на join()-е. Классический тупик.

В результате нужно сделать еще одну RAII обертку, для того, чтобы при раскрутке стека выдать команды на завершение рабочей нити:

void demo()
{
   context first_ctx;
   thread first_thread{ worker, ref(first_ctx) };
   auto_joiner first_joiner{ first_thread };

   class notificator_type {
      context & m_ctx;
   public :
      notificator_type( context & ctx ) : m_ctx{ ctx } {}
      ~notificator_type() {
         lock_guard< mutex > lock{ m_ctx.m_lock };
         m_ctx.m_command = command::close;
         m_ctx.m_cond.notify_one();
      }
   } first_notificator{ first_ctx };

   throw runtime_error{ "something wrong!" };

   {
      lock_guard< mutex > lock{ first_ctx.m_lock };
      first_ctx.m_command = command::close;
      first_ctx.m_cond.notify_one();
   }
}

Вот теперь все завершается как положено.

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

В общем, многопоточность -- дело сложное. Лучше держаться от нее подальше, тем более, что на современной технике простой однопоточный код можеть показать лучшую производительность, что перемудренный многопоточный. А если уж приходится использовать многопоточность, то сперва имеет смысл воспользоваться чужими готовыми инструментами. Пусть у их разработчиков голова болит о том, где, что и как может пойти не так. И лишь в крайнем случае браться за ручное управление голыми потоками вручную.

Комментариев нет: