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

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

Создание потоков

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

Сети автоматов многие проблемы параллелизма решают "на раз", чего у потоков и близко нет. Они, к сожалению, проблемы только множат. В то же время потоки поддержаны аппаратно и внедрены в языки программирования. Аппаратная поддержка делает их эффективными, а языковая - упрощает применение. Но даже все хорошее, что у них есть, мое отношение к ним не меняет. Для этого просто нет предпосылок. Тем не менее, использовать потоки вполне можно и как это делать применительно к среде ВКПа, что это дает, далее и будет показано. 

Для порождения потока в Qt нужно создать потомок класса QThread и перегрузить его метод run(). Но дальше начинаются проблемы. Одна из них - корректное завершение самих потоков и приложения в целом. Чтобы не иметь подобных проблем, было сделано следующее. Во-первых, введен флаг работы потока, названный далее bIfRun. Его установка запускает цикл работы потока, а сброс инициирует выход из цикла. В том числе, если необходимо срочно прервать работу. Так работа потока взята под контроль.

Еще один флаг - bIfExecuteStep учитывает особенности работы ВКПа. Он позволяет синхронизировать работу потока с автоматным пространством среды ВКПа, породившем поток (напомним, что в данном случае мы имеем дело не просто с автоматом, а с потомком класса потока Qt). Данный флаг устанавливается при вызове метода ExecuteThreadStep() средой ВКПа, а сбрасывается в цикле потока. Так осуществляется внешняя синхронизация потока на каждом дискретном такте среды. Отключает подобную синхронизацию локальная переменная класса bIfLiberty. Данный флаг, будучи установленным, запускает поток на максимальной скорости.

Перегруженный метод WaitForThreadToFinish() автоматного класса  содержит код корректного завершения потока. Этот метод вызывается средой перед удалением автоматного процесса. Так работает механизм корректного завершения потока. Если же класс не автоматный, т.е. не является потомком класса LFsaAppl), то этот код нужно включить в деструктор класса.

Нужно не забыть и про вызов метода start(n), где параметр n задает приоритет потока. Без этого вызова поток просто не будет создан. Но место такого вызова определяется уже в рамках функциональности автоматного процесса.

Все описанное схематично ниже демонстрирует код на С++ (см. листинг 1).

Листинг 1. Схема реализации потока автоматного объекта на С++ в ВКПа
FThCounter::~FThCounter(void)
{
    bIfRun = false;
...
    quit();
    wait();
}

void FThCounter::WaitForThreadToFinish() {
    // завершить поток базового автомата
    bIfRun = false;
    quit();
    wait();     // неограниченное ожидание завершения потока
}

void FThCounter::ExecuteThreadStep() { bIfExecuteStep = true; }

void FThCounter::run() {
...
    while (bIfRun) {
        if (bIfExecuteStep || pVarIfLiberty->GetDataSrc()) {
...
                    if (!bIfRun) break;
                }
            }
        }
    }
    bIfRun = false;
}

Описанного выше в большинстве случаев достаточно, чтобы создать поток и управлять им в среде ВКПа (да, скорее всего, и в любом другом месте). Однако, если бы только этим все и заканчивалось... Управлять и работать - разные вещи. А с чем нам еще предстоит столкнуться мы сможем узнать, только тестируя потоки. И, пожалуй, это единственный случай в моей практике, когда ВКПа выполняет чисто служебные функции - запуская, удаляя и отображая результаты работы потока/потоков. Но одновременно это и случай, когда, казалось бы, независимые друг от друга вещи дополняют друг друга, существенно умножая свои возможности.

Автоматные классы тестирования потоков

Создадим автоматные классы, которые будут потомками класса потока библиотеки Qt. Первый класс, назовем его генератор, будет с периодичностью устанавливать/сбрасывать переменную булевского типа. Второй, который назовем счетчиком, будет наращивать в цикле  значение целой переменной до заданного значения. Он же поможет нам оценить скоростные характеристики процессов и имитировать одновременный доступ к общему ресурсу.

Значение переменных, которые используют данные классы, будем отображать, используя графические возможности среды ВКПа - ее осциллограф. При этом визуально генератор должен порождать периодический сигнал прямоугольной формы, а счетчик - линейный график изменения переменной, линейность и наклон которого будут отражать качество и скорость работы процесса. Счетчик также будет создавать пул потоков, каждый из потоков которого будет в свою очередь счетчиком. Потоки будут обращаться к переменной, имитирующей общий ресурс. Будет организована возможность использования примитивов синхронизации - мютекса или семафора.

Счетчик, кроме порождения параллельных процессов, представленных пулом потоков, будет создавать собственно автоматный процесс среды ВКПа, а также параллельные ему - процесс управляемый событиями таймера и еще один - собственный поток.  Создание последних, назовем их так, двух типов процессов - таймерного и поточного возможно потому, что автоматные класс наследует подобные возможности, являясь потомком класса потока библиотеки Qt (а на нижнем уровне иерархии наследования - потомком класса QObject).

Код потока генератора представлен на листинге ниже (листинг 2):

Листинг 2. Модель генератора на потоке
void FThread::run() {
    nState = 3; // начальное состояние модели потока
    while(bIfRun)  {
        beg:
        if (bIfExecuteStep || pVarIfLiberty->GetDataSrc()) {
            if (bIfExecuteStep) { bIfExecuteStep = false; }
            switch(nState) {
            case 3: y3(); nState = 4; break;
            case 4: y4(); nState = 3; break;
            }
        }
        // задержка потока (в мксек)
        if (pVarStrSleep->strGetDataSrc().length()>0)
            usleep(QString(pVarStrSleep->strGetDataSrc().c_str()).toInt());
        if (bIfExecuteStep) goto beg;
    }
}
// получить текущее состояние потока
string FThread::FGetState(int nNum) {
    if (bIfRun) {
        switch(nState) {
        case 3: return "t_s3"; break;
        case 4: return "t_s4"; break;
        default: return "error state";
        }
    }
    else return LFsaAppl::FGetState(nNum);
};

Код генератора демонстрирует "автоматную реализацию" функций в потоке. В нее также введено внутренне состояние, которое можно в реальном времени контролировать с помощью перегруженного метода FGetState() автоматного класса.  Скорость потока и, соответственно,  период выходного сигнала генератора  можно регулировать, погружая поток на какое-то время в "сон" с помощью метода usllep(n), параметр которого задает время "сна" в микросекундах (если просто sleep(n), то в миллисекундах).  Весьма желательно - вот он первый добытый и достаточно неожиданный секретик! - иметь возможность вызов данного метода исключать, т.к., запущенный даже с нулевым значением параметра, он весьма заметно влияет на скорость работы потока.

Код потока счетчика представлен в листинге 3. Здесь, а это уже наш секретик, нулевое значение длины строковой переменной, задающей числовое значение параметра "сна", позволяет отключить вызов метода usleep().

Листинг 3. Модель счетчика на потоке
void FThCounter::run() {
    int n=0;
    pVarTimeThrFSA->SetDataSrc(nullptr, 0.0);
    while (bIfRun) {
        if (bIfExecuteStep || pVarIfLiberty->GetDataSrc()) {
            if (pVarCounterThrFSA) {
                timeThr.start();
                while (n<pVarMaxValue->GetDataSrc()) {
                    if (bIfExecuteStep || pVarIfLiberty->GetDataSrc()) {
                        if (bIfExecuteStep) { bIfExecuteStep = false; }
                        int nCntr = pVarCounterThrFSA->GetDataSrc();
                        pVarCounterThrFSA->SetDataSrc(nullptr, ++nCntr);
                        pVarCounterThrFSA->UpdateVariable();
                        if (pVarStrSleepThrFSA->strGetDataSrc().length()>0) {
                            usleep(QString(pVarStrSleepThrFSA->strGetDataSrc().c_str()).toInt());
                        }
                        n++;
                    }
                    if (!bIfRun) break;
                }
            }
        }
        pVarTimeThrFSA->SetDataSrc(nullptr, timeThr.elapsed());
        break;
    }
    bIfRun = false;
}

В соответствии с выбранным режимом работы поток  может быть или синхронизирован со средой ВКПа, или работать в свободном режиме со скоростью, определяемой значением метода usleep(), или с максимальной скоростью, если такой вызов отсутствует.

Несколько отличается код счетчика из пула потоков. Его демонстрирует листинг 4. Отметим одну важную особенность данного класса. Поскольку он не является потомком автоматного класса, то код корректного завершения потока (см. листинг 1) включен в его деструктор.

Листинг 4. Модель счетчика с синхронизаций потоков
void ThCounter::run() {
    QMutex m_mutex;
    int n=0;
    while (n<nMaxValue && bIfRun ) {
        bool bSm = pFThCounter->pIfSemaphoreLotThreads->GetDataSrc();
        bool bMx = pFThCounter->pIfMutexLotThreads->GetDataSrc();
        if (bSm || bMx) {
            if (bSm) pFThCounter->AddCounterSem();
            else pFThCounter->AddCounterMx();
        }
        else pFThCounter->AddCounter();

        if (!pFThCounter->pIfTimerLotThreads->GetDataSrc()) {
            QString qstr = pFThCounter->pVarStrSleepLotThreads->strGetDataSrc().c_str();
            if (qstr.length()>0) {
                int nSleep = qstr.toInt();
                usleep(nSleep);
            }
        }
        n++;
    }
    pFThCounter->DecrementActive();
}

Поток из пула потоков может синхронизировать работу с другими потоками, используя примитивы синхронизации - мютекс или семафор, или работать в асинхронном режиме. Метод DecrementActive() вызывается при завершении работы потока для уменьшения счетчика активных потоков. Его нулевое значение информирует о завершении работы пула.

Коды методов автоматного класса (ссылка на него есть в каждом потоке), которые изменяют значение счетчика, представлены в листинге 5. Методы - AddCounterMx(), AddCounterSem() и AddCounter() вызываются в зависимости  от выбранного режима синхронизации. При этом, если установлена ссылка на объект общего ресурса, изменение общего счетчика происходит через него. В такой ситуации именно этот объект содержит ссылку на общий ресурс. Когда ссылка на объект общего ресурса не установлена, то  общий счетчик принадлежит текущему автоматному объекту и недоступен напрямую другим объектам.

Листинг 5. Методы изменения счетчика в различных режимах тестирования
void FThCounter::DecrementActive() {
    m_mutex.lock();
    pCSetVarThread->nActive--;
    m_mutex.unlock();
}

void FThCounter::AddCounter() {
    if (!pFSharedResource) {
        int nVal = pVarExtrCounter->GetDataSrc();
        pVarExtrCounter->SetDataSrc(nullptr, ++nVal);
    }
    else {
        if(pFSharedResource->pVarExtrCounter) {
            pFSharedResource->AddCounter();
        }
    }
    int nMyCnt = pVarMyCounter->GetDataSrc();
    pVarMyCounter->SetDataSrc(nullptr, ++nMyCnt);
    pVarMyCounter->UpdateVariable();

}

void FThCounter::AddCounterMx() {
    string str = FGetNameVarFSA();
    if (!pFSharedResource) {
        m_mutex.lock();
        int nVal = pVarExtrCounter->GetDataSrc();
        pVarExtrCounter->SetDataSrc(nullptr, ++nVal);
        m_mutex.unlock();
    }
    else {
        if(pFSharedResource->pVarExtrCounter) {
            pFSharedResource->AddCounterMx();
        }
    }
    int nMyCnt = pVarMyCounter->GetDataSrc();
    pVarMyCounter->SetDataSrc(nullptr, ++nMyCnt);
    pVarMyCounter->UpdateVariable();
}

void FThCounter::AddCounterSem() {
    if (!pFSharedResource) {
        int n = m_semaphore.available();
        m_semaphore.acquire(1);
        n = m_semaphore.available();
        int nVal = pVarExtrCounter->GetDataSrc();
        pVarExtrCounter->SetDataSrc(nullptr, ++nVal);
        m_semaphore.release(1);
        n = m_semaphore.available();
    }
    else {
        if(pFSharedResource->pVarExtrCounter) {
            pFSharedResource->AddCounterSem();
        }
    }
    int nMyCnt = pVarMyCounter->GetDataSrc();
    pVarMyCounter->SetDataSrc(nullptr, ++nMyCnt);
    pVarMyCounter->UpdateVariable();
}

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

Локальная переменная класса с именем  "nMyCouner" (ссылка - pVarMyCounter) содержит число обращений на изменение счетчика. Она введена, т.к. в общем случае значение общего счетчика, который изменяется множеством потоков, не обязательно может быть равно числу обращений на его изменение  через текущий класс.

Тестирование потоков

Графики на рис. 1 отражают работу пула потоков, порождаемых одним объектом класса FThCounter при максимальном значения счетчика - 1000. Цифры, которыми помечены графики, отражают число потоков в пуле: 1- 1000 потоков, 2 - 2000, 3 - 3000, 4 - 4000, 5 - 5000. Время работы пула потоков до достижения максимального значения общего счетчика можно оценить числом клеток до "полки" (одна клетка - 10 сек). В эксперименте потоки для синхронизации используют мютекс, а их дискретное время - 10 мсек (каждый из потоков вызывает функцию "засыпания" usleep(n), где n = 10000).

Оценим реальную эффективность пулов потоков в сравнении с идеальной. В идеале скорость любого числа параллельных потоков для 1000 циклов при дискретном времени 0.01 сек равна 10-ти секундам. Или, другими словами, она равна времени работы одного потока (ведь, потоки одинаковы и параллельны). Наш тест показывает примерно 11-12 сек.. В какой-то мере, это терпимо. Можно предположить, что примерно такое же время будет при числе потоков до 1000. Для 2000 потоков имеем уже 17,7 сек, 3000 потоков - 30.8 сек, 5000 - более 70-ти сек и 10000 потоков сделают работу за 125.486 сек. Таким образом, оптимальный "потолок" потоков, отражающих в разумных пределах идеальную скорость задачи, находится в пределах 1000 потоков. Далее ситуация, с точки зрения скорости работы, конечно, будет только ухудшаться. В конечном счете все будет зависеть от используемой аппаратной конфигурации.

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

Рис. 1. Тестирование различного числа потоков
Рис. 1. Тестирование различного числа потоков

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

Рис. 2. Тесты пулов потоков: 1 - до 1000 потоков, 2-2000 потоков, 3 - 3000, 4 - 5000, 5 - 10000 потоков
Рис. 2. Тесты пулов потоков: 1 - до 1000 потоков, 2-2000 потоков, 3 - 3000, 4 - 5000, 5 - 10000 потоков

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

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

Рис. 3. Тест на стабильность работы потоков
Рис. 3. Тест на стабильность работы потоков

Но вернем к статье. Оценим стабильность работы потоков. На рис. 3 показаны результаты тестирования нескольких последовательных экспериментов для пулов потоков из 2000 и 5000 экземпляров. Две тысячи потоков отработали достаточно стабильно. Это можно было бы сказать и про пул из пяти тысяч, если бы не один выпадающий эксперимент со временем почти 129 сек (более чем в два раза больше ожидаемого). Но подобные задержки, как показывает опыт,  могут возникнуть в любой момент и при любом числе потоков. Этим процессом "рулит" система. Можно лишь сказать, что вероятность "тормозов" увеличивается при увеличении числа потоков.  Теперь понятны причины, по которым Windows не относят к системам реального времени: потоки могут подвести в самый неподходящий момент времени. Не потому ли мы не так давно лихо врезались в Луну? ;)

Довольно неожиданной (по аналогии с функцией sleep())  оказалась и разница в работе с объектами синхронизации: мютексами - QMutex и семафорами - QSemaphore. Рис. 4 демонстрирует результаты для вариантов из 100, 200 и 300 чистых (без вызова usleep()) потоков и максимальном значении счетчика - 1000. Я настолько впечатлен результатом, что сомневаюсь в правильном использовании семафоров (все же я начинающий "многопоточник"), а потому привожу код функций с мютексом и семафором (листинг 6). Так что, может, пока не будем спешить с объявлением еще одного обнаруженного секрета потоков? Ну, а вдруг?

Рис. 4. Тест объектов синхронизации.
Рис. 4. Тест объектов синхронизации.

Листинг 6. Методы счетчика, использующие объекты синхронизации мютекс и семафор.
void FSharedResource::AddCounterMx() {
    m_mutex.lock();
    int n = pVarExtrCounter->GetDataSrc();
    pVarExtrCounter->SetDataSrc(nullptr, ++n);
    pVarExtrCounter->UpdateVariable();
    if (pVarMyCounter) {
        int nMyCnt = pVarMyCounter->GetDataSrc();
        pVarMyCounter->SetDataSrc(nullptr, ++nMyCnt);
        pVarMyCounter->UpdateVariable();
    }
    m_mutex.unlock();
}

void FSharedResource::AddCounterSem() {
    m_semaphore.acquire(1);
    int nVal = pVarExtrCounter->GetDataSrc();
    pVarExtrCounter->SetDataSrc(nullptr, ++nVal);
    pVarExtrCounter->UpdateVariable();
    if (pVarMyCounter) {
        int nMyCnt = pVarMyCounter->GetDataSrc();
        pVarMyCounter->SetDataSrc(nullptr, ++nMyCnt);
        pVarMyCounter->UpdateVariable();
    }
    m_semaphore.release(1);
}

Рассмотрим теперь, как на работу потоков влияет синхронизация. В контексте этого мне не дает покоя статья [1], в которой для экспериментов взяты десять потоков и единая переменная-счетчик. При этом максимальное число циклов отдельного потока - 1000000. Если убрать синхронизацию, то при таких же исходных данных наш тест выполнит задачу за время примерно равное 1,8 сек. Правда, значение счетчика при этом будет непостоянным в диапазоне от 7e+006 до 8,5e+006 (усредненные результаты нескольких экспериментов). Т.е. неконтролируемый доступ к общему ресурсу порождает и непредсказуемый результат. И куда спешим?

Возьмем для синхронизации процессов мютекс. Время будет уже много больше - 46,893сек, но при этом получим вполне ожидаемый правильный результат. Но, если это будет семафор, то время увеличится до неприличных 302,316сек при опять же правильном результате.

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

Результаты экспериментов сведены в таблицу табл.1. Заданное максимальное значение счетчика - 10000. Столбцы соответствуют разным типам процессов - автоматному, таймерному и на потоке. Строки - разным значениям дискретного времени. Первая строка таблицы - эксперимент, когда дискретное время не задано. В этом случае автомат работает в асинхронном режиме, значение таймера у процесса на его базе - 0, у потока нет вызова функции usleep(). 

Наиболее стабильные результаты демонстрирует автомат, у таймерного процесса погрешность доходит до 10%, у потока - от 20% и может быть даже много больше (вспомним про "тормоза"). Наиболее примечательны результаты первой строки, где поток показывает недостижимую для других процессов скорость, которая при использовании любой синхронизации или даже просто вызова функции "сна" с нулевым параметром стремительно падает.

Тип процесса

(v=10000)

                Время такта (dt)

 

FSA

 

timer event

 

Thread

-

2344

8265

1

1 msec

9793

10586

24322

2 msec

19847

20056

31553

10 msec

99845

100028

120991

100 msec

999980

1092093

1032146

Выводы

Читаю: "Горутины виснут непонятно почему, случайная запись в закрытый канал вызывает panic, нормально протестировать приложение вообще невозможно... написать мало-мальски серьезную программу, которая конкурентно что-то делает, внезапно оказывается не так-то просто." [2]. Ну, прямо, как говорится, в строку. Думаю, учитывая результаты выше проведенных экспериментов, описанное поведение горутин вполне объяснимо...

Рис. 5. Тестирование объектов синхронизаци
Рис. 5. Тестирование объектов синхронизаци

По мне использование потоков для реализации параллельных процессов ограничено определенными рамками. При этом первые кандидаты на поточную реализацию - процессы, не требующие синхронизации. И уж совсем хорошо, если их скорость, точнее, ее равномерность,  не будет критичным фактором. Для пояснения этого на рис. 5 приведена еще одна визуализация экспериментов с синхронизацией потоков. Картина представлена для 10-ти потоков, без функции "засыпания" и максимальном значении счетчика - 50000 (ср. с рис. 4). Может это случайно, но видно насколько "криво" отрабатывают семафоры. А в контексте данной статьи это только крайний, но, скорее всего, не последний секрет потоков.

Литература:

1.    Многопоточность в Python: очевидное и невероятное. [Электронный ресурс], Режим  доступа: https://habr.com/ru/articles/764420/ свободный. Яз. рус. (дата обращения 01.06.2024).

2.    Sructured concurency в языке Go. [Электронный ресурс], Режим  доступа: https://habr.com/ru/companies/karuna/articles/816677/ свободный Яз. рус. (дата обращения 01.06.2024).

PS

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

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

На рис.6 такие проблемные области выделены и помечены одинаковыми метками.  Области, помеченные 1, демонстрируют, как достаточно быстро генераторы на потоках вошли в противофазу. Заметим, что автоматные же генераторы - стабильны.  Области, помеченные как 2 и 3, показывают, как не столь уж заметные проблемы в работе потоков (их отражает нелинейность графика счетчика) влияют на работу соответствующих им моделей генераторов. Причем, заметим, визуально в один и тот момент времени, но по-разному на разные генераторы. Но генераторы на автоматах, что примечательно, по-прежнему сохраняют свою стабильность!

Рис. 6. Тест генераторов
Рис. 6. Тест генераторов

Вот кратко, пожалуй, и все про генераторы. Главное, что они хорошо демонстрируют стабильную и качественную работу параллельных процессов на автоматах  и наличие проблем в работе  потоков.

Вспомним еще раз цитату про проблемы языка Go. Но прибегать нынче к структурному программированию - это же, как сейчас сказали бы, "полный трэш". Уж тогда просмотрите хотя бы сорокалетней давности книгу Э.Йодана (см. [3] ниже), в которой упомянут метод Ашкрофта и Манны - метод введения переменной состояния (выше в коде любого потока его аналог). А рекомендациям, которые высказаны на ст. 277 его же книги,  я следую, пожалуй, уже не один десяток лет. И, честное слово, повозившись нынче с потоками, ни чуть об этом не жалею. 

И если когда-то давно Дейкстра призвал к отказу от оператора goto, то в ВКПа на уровне модели автомата не используется вообще какой-либо из  операторов передачи управления современных языков.  Другими словами, откажитесь от операторов управления современных языков и вам будет счастье! Правда, с одной оговоркой: на уровне предикатов/действий автоматов их применение вполне обосновано.  Но только из чисто практических соображений (с точки зрения теории в них нужды совсем нет). И, если вы обратили внимание, применение операторов управления ограничено узкими рамками, как правило, весьма небольших функций, а потому легко и надежно контролируется.

Так сказать, - структурное программирование в своей идеальной автоматной форме!

Литература:

3. Йодан Э. Структурное проектирование и конструирование программ. М.: Мир,1979 - 415с.

Комментарии (10)


  1. GAlex1980
    03.06.2024 09:24

    wait(); // неограниченное ожидание завершения потока

    Результатом может быть "замораживание" графического интерфейса (если он будет использоваться), очереди событий и т.д. Думаю, лучше использовать подобный код:

    void waitForEndThread(QThread *obj, unsigned long time)
    {
        while (!obj->wait(time)) {
            QCoreApplication::processEvents();
        }
    }


    1. lws0954 Автор
      03.06.2024 09:24

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

      ThCounter::~ThCounter(void) {
          bIfRun = false;
          QThread *obj = QThread::currentThread();
          obj->quit();
          bool w = obj->wait(10000);
          if (nIdTimer) killTimer(nIdTimer);
          nIdTimer = 0;
      }
      

      От следующего

      ThCounter::~ThCounter(void) {
          bIfRun = false;
          quit();         // корректный способ завершения потока
          wait(10000);         // ожидание завершени потока (неограниченное ожидание wait())
                          // подробнее см. Боровский А. Qt4.7... стр.170
          if (nIdTimer) killTimer(nIdTimer);
          nIdTimer = 0;
      }
      

      А они отличаются. Функциональностью. ОБъясню в чем проблема. Нижний "зависает". на wait(n). Верхний - нет. Для меня - загадка. Казалось бы, если я задал параметр методу wait, то на нем и "вис" будет это же время. Так работает нижний код. ВЕрхний почему-то работает иначе, т.е., несмотря на указанный параметр, выход из wait происходит тут же. Это и позволяет не блокировать интерфейс. Можно грешить на то, что возвращается указатель не на текущий поток. Пусть так. Но все равно - почему фукции wait отрабатывают по-разному?

      Повторюсь. Мне хотелось бы знать, как быстро "убить" поток. Но как?


      1. GAlex1980
        03.06.2024 09:24

        Подозреваю, что оба фрагмента кода будут отличаться набором ошибок и количеством утекаемой памяти.

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

        ThreadWorker worker; // либо через оператор new
        worket.init(setup_params); //необязательное. setup_params набор параметров, которые надо отдать в поток
        worket.start(); //начинается выполнение содердимого переопределенного метода run()
        worker.waitForEndThread(100); //мой метод, описанный выше
        //... other code

        В строке 4 внутри метода каждые 100 мс wait() будет сбрасываться в true и будет обрабатываться очередь сообщений (сигналы слотам). Потом в цикле все будет повторяться, пока не будет выполнен выход из метода run().

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

        Вообще, потоки - специфическая вещь и для каждой конкретной задачи нужна своя реализация.

        Пока я писал ответ, просмотрел весь ваш код - в вашем случае, возможно, стоило создавать потоки через moveToThread(). Остальное советую глянуть в справке по Qt - там есть примеры.


        1. GAlex1980
          03.06.2024 09:24

          В деструкторе ThreadWorker::~ThreadWorker() удалять динамически созданные члены класса, закрыть используемые ресурсы и т.д.


          1. lws0954 Автор
            03.06.2024 09:24

            Поток - это независимая задача, которая выполняется внутри процесса и разделяет с ним общее адресное пространство, код и глобальные данные.

            Казалось бы простая и прозрачная концепция. И я точно также могу определить, что ...

            Автомат - это независимая задача, которая выполняется внутри процесса и ... далее по тексту.

            Вся разница в том, что поток - это поток, а автомат - это внутри одного потока. Именно так это сейчас, если говорить об автоматах. Другая разниза - .в модели управления. У потока - это блок-схемная модель, у автомата - это соотвественно автомат.

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

            Предположим, я решусь на подобное. Сейчас автомат - это потомок моего автоматного класса LFsaAppl. Сделать его еще и потомком класса QThread - пару секунд. Т.е. уже это сделано (см. мою статью). Но замена "двигателя" сразу породила проблемы: что-то перестало работать. Я уж молчу про саму отладку, с которой у меня с автоматами совсем нет проблем.

            И вот я возился с QThread больше месяца, постигая все чудачества потоков. И тут появляется, как я понял, его аналог - ThreadWorker. И где гарантии, что я не приду к такому же итогу?...

            А потому. за информацию огромнейшее спасибо. Отойду от QThread и возьмусь, может быть, за ThreadWorker. Но подозреваю, что поменяв "двигатель" я буду сильно жалеть о старом ;) Ведь, на нем, кроме скорости, меня все устраивало. А тут со скоросьтю будет моментами все просто прекрасно, но без каких либо гарантий доехать до места.

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

            Пока же, чтобы разгрузить автоматное ядро, я проверенные автоматы перевел на таймеры (а планы были на потоки). Так у меня теперь работает весь севис среды ВКПа. Это проверенный и всегда (пока проблем не было) работающий примем. Но стоит только перейти какому-то процессу на поток - бац! - проблемы. Конкретный пример. В КПа есть процесс, который я называю осциллограф (все графики на нем) . Был автоматным - работал. Стал таймерным - работает. Переношу на поток - не работает от слова совсем. И попробй докопаться. Хотя именно только включение одного осциллографа тормозит ядро больше чем на порядок. На том же тесте счетчиков имеем: дискретный такт без осциллографа - 0.28 мсек, а с ним - 2.8 мсек. Но все мои заходы на "поточную графику" пока ни к чему не приводят. Хотя и совсем бесполезными их не считаю :)


            1. GAlex1980
              03.06.2024 09:24

              Дабы не плодить на техническом ресурсе бесполезную лирику, предлагаю проанализировать то, что написано в книге Боровский и у вас. Тем более у меня нет русской раскладки, чтобы быстро отвечать вам.
              У Боровский в методе run() выполняется:

              • создание локальной переменной, ее инициализация (параметры, связывание через connect())

              • запуск

              • вызов exec()

              In the Qt Documentation:
              int QThread::exec()
              Enters the event loop and waits until exit() is called, returning the value that was passed to exit(). The value returned is 0 if exit() is called via quit().
              This function is meant to be called from within run(). It is necessary to call this function to start event handling.
              Note: This can only be called within the thread itself, i.e. when it is the current thread.

              О, есть цикл обработки событий внутри дочернего потока.
              Идем далее.
              У меня нет полных исходников примера из Боровский, но переменная thread объявлена в классе главного окна. Ее срок жизни - срок жизни приложения, т.е. главного потока.
              Смотрим, что будет при завершении приложения.
              Смотрим деструктор:

              • проверка того, что thread != nullptr

              • вызов quit()

              • вызов wait() //с дефолтными параметрами

              In the Qt Documentation:
              void QThread::quit()
              Tells the thread's event loop to exit with return code 0 (success). Equivalent to calling QThread::exit(0).
              This function does nothing if the thread does not have an event loop.
              Note: This function is thread-safe.

              bool QThread::wait(QDeadlineTimer deadline = QDeadlineTimer(QDeadlineTimer::Forever))
              Blocks the thread until either of these conditions is met:
              The thread associated with this QThread object has finished execution (i.e. when it returns from run()). This function will return true if the thread has finished. It also returns true if the thread has not been started yet.
              The deadline is reached. This function will return false if the deadline is reached.
              A deadline timer set to QDeadlineTimer::Forever (the default) will never time out: in this case, the function only returns when the thread returns from run() or if the thread has not yet started.
              This provides similar functionality to the POSIX pthread_join() function.

              Ага, есть метод отправки сигнала (QThread::quit()) на завершение выполнения дочернего потока, которую примет цикл обработки событий (QThread::exec()).
              После получения сигнала на завершение, будет выход из метода run(), который по сути означает завершение выполнения потока и освобождение ресурсов.

              Мы ждем корректного завершения потока (QThread::wait()) и освобождения связанных с ним ресурсов во избежание их утечки. Так как в примере в потоке ничего особенного не выполняется, то задержка на wait() будет мизерной.

              Код примера из Боровский полностью корректный для использования в той ситуации.

              Теперь смотрим на ваш код (Листинг 1).
              Внутри метода run() есть цикл обработки данных и нет цикла обработки событий.
              Внутри FThCounter::~FThCounter() и FThCounter::WaitForThreadToFinish():

              • вызов quit()

              • вызов wait() //с дефолтными параметрами

              Касательно разницы в работе. Надо смотреть под отладчиком, что именно возвращает QThread::currentThread() и есть ли потоки с таким ID (см справку).

              Метод quit() отправляет команду на завершение выполнения дочернего потока. Вот только чем и где он будет принят - я сказать не могу. У меня только одна ассоциация: "Uciekł gdzie pieprz rośnie" (в неизвестном направлении). Нужно смотреть исходники QThread, но ваш код - потенциальный UB.
              Метод wait() - может ждать бесконечно долго или сразу возвратить true, в зависимости от состояния экземпляра класса FThCounter.

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

              Мне хотелось бы знать, как быстро "убить" поток. Но как?

              Добавьте возможность обработки очереди сообщений в основном или дочернем потоке. Тогда и фризы исчезнут и потоки будут вовремя убиваться.


          1. lws0954 Автор
            03.06.2024 09:24

            Что-то не нашел класса ThreadWorker? Где он находится или из какой библиотки взят?


            1. GAlex1980
              03.06.2024 09:24

              Это просто абстрактный пример класса, который реализует поток. Хотя, примеры взяты из кода моих проектов.


  1. DungeonLords
    03.06.2024 09:24

    "Для порождения потока в Qt нужно создать потомок класса QThread и перегрузить его метод run(). "
    Разве от этого многообразие средств Qt будет утеряно. Вот нужен мне в этом отдельном потоке QTimer... И как его использовать? А механизм signal-slot будет работать в экземпляре класса с переопределенным run? Может быть лучше использовать thread and worker подход? Я написал такой пример. Он использует thread and worker подход, но в отличие от всего того, что встречал в интернете, у меня смарт points. Хотел бы получить конструктивную критику по своему примеру...


    1. lws0954 Автор
      03.06.2024 09:24

      ...Разве от этого многообразие средств Qt будет утеряно.

      С Qt все останется так, как и было. Станет только лучше - появятся автоматы в дополнение к потокам и всему тому, что есть в Qt.

      Вот нужен мне в этом отдельном потоке QTimer... И как его использовать?

      С этим похоже, действительно, проблемы. А вот один таймер в поток устроен по умолчанию. С ним и его использованием проблем нет. Так что не все так уж плохо.

      Хотел бы получить конструктивную критику по своему примеру...

      Если вопрос ко мне, то пока я не дорос до такого. В потоках я еще только начинающий ;)