Многопоточность
Процесс — экземпляр программы во время выполнения, независимый объект, которому выделены системные ресурсы (например, процессорное время и память). Каждый процесс выполняется в отдельном адресном пространстве: один процесс не может получить доступ к переменным и структурам данных другого. Если процесс хочет получить доступ к чужим ресурсам, необходимо использовать межпроцессное взаимодействие.
Начнем с начала — с определений — Что такое процесс?
Грубо говоря процесс это одно отдельно запущеное приложение.
Процессу выделены какие-те ресурсы и он живет в своем отдельном адресном пространсте и никто в его домик проникнуть не может.
Если ему или другим процессам понадобится обмениваться данными им придется использовать межпроцесные взаимодействия: например файл, сокет, канал, файл в памяти, очередь сообщений или RPC
Что такое поток?
Поток использует то же самое пространства стека, что и процесс, а множество потоков совместно используют данные своих состояний. Каждый поток может работать (читать и писать) с одной и той же областью памяти, в отличие от процессов, которые не могут просто так получить доступ к памяти другого процесса. У каждого потока есть собственные регистры и собственный стек, но другие потоки могут их использовать.
Процессы и потоки
Вот тут мы видим упрощенную схему того как приложения выполняются на комьютере. Процессы имеют разные области памяти, которые как-то мапятся на физическую память, потоки живут внутри процесса и имеют какие-то свои данные и стек. К этой картинке мы еще вернемся, а пока идем дальше
Как представлен поток в JAVA?
- Поток — объект у класса которого есть методы run() и start()
- После вызова метода start(), через какое-то время будет вызван run()
- Метод run() будет выполнен в своем стеке.
Роль операционной системы
Вот этим «через некоторое время будет запущем метод run()» занимается операционная система, а точнее её планировщик потоков, который переключает контексты исполнения, раскладывая потоки на реальные ядра. То есть, когда мы вызываем у нашего объекта поток метод start(), мы говорим операционной системе:»Запусти пожалуста код, что находится в методе run() в отдельном потоке» и она спустя какое-то время это делает
Роль операционной системы
- Создает потоки
- Переключает потоки
- API для уведомления потока
Запуск потоков
На данной картинке мы видим как происходит запуск потоков во времени. Запускать потоки мы можем в любом месте кода, один поток может породить другой(что здесь и происходит). На этой картинке всё понятно и предсказуемо.
Порядок не определен!
Но вот на этой уже проглядывается проблема, операционная система нам не гарантирует, что потоки будут запущены, в той последовательности в которой мы звали методы start. Если вам понадобится, что бы разные потоки выполняли работу в нужной вам последовательности придется приложить дополнительные усилия
C#. Понятие потока. Архитектура потоков в C#. Потоки с опорными хранилищами. Потоки с декораторами. Адаптеры потоков
Понятие потока. Архитектура потоков в C#. Потоки с опорными хранилищами. Потоки с декораторами. Адаптеры потоков
Содержание
- 1. Что такое поток в программировании? Понятие потока
- 2. Архитектура потоков в .NET. Категории потоков
- 3. Потоки с опорными хранилищами. Обзор
- 4. Потоки с декораторами. Обзор
- 5. Адаптеры потоков. Назначение. Обзор
Поиск на других ресурсах:
1. Что такое поток в программировании? Понятие потока
В программировании поток (stream) — это логическое устройство, предусматривающее:
- потребление (получение) информации. В этом случае определяют термин поток ввода;
- выработка (передача) информации. В этом случае определяют термин поток вывода.
Поток представляет собой абстракцию, которая обеспечивает ввод/вывод информации в программе. Система ввода/вывода связывает поток с физическим устройством (рисунок 1). Работа потока на ввод или на вывод содержит одинаковый набор команд независимо от физического устройства. Так, например, вывод на принтер или экран осуществляется одинаковыми вызовами функций или вывод на консоль работает так же как и вывод в файл. В свою очередь, одна и та же функция может работать с различными типами физических устройств.
Рисунок 1. Взаимодействие потока с различными типами физических устройств ввода/вывода (принтер, удаленный компьютер, файл)
2. Архитектура потоков в .NET. Категории потоков
В технологии .NET потоки делятся на две основные категории (рисунок 2):
- потоки с опорными хранилищами;
- потоки с декораторами.
Потоки с опорными хранилищами реализуют конкретный вид хранилища, которым может быть:
- файл;
- память;
- сеть;
- изолированное хранилище.
Потоки с декораторами реализуют модификацию данных, передаваемых в опорные хранилища. Примерами такой модификации могут быть:
- шифрование данных перед отправкой в сети;
- архивирование данных;
- сжатие данных и их распаковки известными методами;
- буферизация данных.
Для модификации уже существующего потока, потоки с декораторами используют подход, заложенный в паттерне Декоратор. Подобные схемы использования паттерна Декоратор применяются и в других языках программирования (например, Java).
Рисунок 2. Архитектура потоков в .NET
Обе категории потоков работают исключительно с байтами. Для представления байтов в текстовом, понятном для человека, виде, используются адаптеры потоков.
3. Потоки с опорными хранилищами. Обзор
Потоки с опорными хранилищами связаны с определенным типом хранилища: файлы, память, сеть и тому подобное. Основные потоки с опорными хранилищами представлены следующими классами:
- FileStream — класс, обеспечивает поток для файла. Класс содержит разнообразные средства обработки файлов. Эти средства обеспечивают как синхронное и асинхронное чтение из файла, так и синхронную и асинхронную запись в файл;
- IsolatedStorage — абстрактный класс, который служит базовым для классов, реализующих доступ к изолированному хранилищу для файлов;
- MemoryStream — класс, предназначенный для обработки потоков, которые размещаются в памяти;
- NetworkStream — класс, содержащий средства представления потока данных в сети.
4. Потоки с декораторами. Обзор
Потоки с декораторами реализуют модификацию (трансформацию) передаваемых данных в опорные хранилища для их хранения или иного использования. Потоки с декораторами используют паттерн Декоратор для модификации существующего потока данных в нужный. Ниже перечислены основные классы, которые обеспечивают работу потоков с декораторами:
- BufferedStream — класс, содержащий средства буферизации при чтении данных из потока и записи данных в поток. Чтение/запись данных осуществляется через буфер — участок памяти заданного размера;
- DeflateStream — класс, обеспечивающий методы для сжатия и распаковки потоков данных. Класс использует Deflate-алгоритм сжатия без потерь;
- GZipStream — класс, реализующий методы и свойства для сжатия/распаковки данных потока на основе спецификации формата данных GZip;
- CryptoStream — класс, осуществляющий над потоком данных криптографические преобразования.
Потоки с декораторами выделены в отдельный раздел классов в архитектуре .NET. Такое представление дает следующие преимущества:
- потоки с декораторами отделяют операции шифрования, сжатия и другие от операций, применяемых в потоках с опорными хранилищами;
- использование потоков с декораторами освобождает потоки с опорными хранилищами от необходимости выполнения шифрования, сжатия и т.д.;
- декорирование потоков не зависит от изменения интерфейса в программе;
- потоки-декораторы можно подключать во время выполнения;
- поддержка паттерна Декоратор дает возможность объединять декораторы в цепочки (например, шифрование + сжатие).
5. Адаптеры потоков. Назначение. Обзор
Адаптеры потоков относятся к более высокому уровню взаимодействия с программой. Они позволяют конвертировать байтовые потоки (потоки с декораторами, потоки с опорными хранилищами) в конкретный формат.
Адаптеры потоков работают по единому принципу: они помещают байтовый поток в оболочку адаптерного класса с соответствующими методами. Эти методы выполняют преобразование байтового потока данных к нужному формату (например, получение XML-формата данных).
Потоки в Java: что это такое и как они работают
Выжимаем максимум из процессора и заставляем программы на Java выполнять несколько задач одновременно.
Иллюстрация: Merry Mary для Skillbox Media
В многоядерных процессорах все ядра параллельно выполняют свои наборы машинных инструкций. Поэтому современные компьютеры довольно быстро справляются со сложными вычислительными задачами.
Но и одноядерную систему можно настроить так, чтобы она работала над несколькими наборами инструкций как бы одновременно — то есть переключалась между ними очень быстро и незаметно для пользователя. При этом за каждую подзадачу будет отвечать своё «виртуальное» ядро, или поток (его ещё называют thread, то есть «нить»). Это и есть многопоточность. Разберёмся, что это такое и как её настроить.
Что такое поток в Java
Представьте работника лаборатории, которому выдали список дел. Он может выполнять каждое строго как написано: закончив первое дело, переходить ко второму, потом к третьему — и так до самого конца. А может решать несколько задач параллельно: например, загрузить компоненты в миксер, а пока идёт их перемешивание, делать навески для следующей загрузки или писать отчёт о результатах вчерашней работы. Возможно, при таком подходе дело пойдёт значительно быстрее.
В программировании то же самое: можно разбить большое задание на подзадачи и распределить их между потоками. Это такие абстрактные сущности, которые последовательно выполняют инструкции программы. Потоки протекают в процессе, а процесс, простыми словами, — это любая запущенная программа.
Любой процесс имеет минимум один поток, который называют главным. Он запускается в первую очередь, а остальные идут параллельно. Например, при запуске программы на Java процесс — это её среда исполнения (JRE).
Обычные программы на Java работают синхронно: строчки кода выполняются одна за другой в главном потоке. Но можно создать несколько тредов и управлять ими. Допустим, один может просто ждать, пока выполнится другой, а может в это время что-то вычислять.
Кроме «виртуальных» тредов, есть аппаратные, о которых говорилось в начале статьи. Они представляют собой «среды исполнения» программных тредов вашего кода. Когда код на Java заточен под несколько тредов, система задействует столько же реальных потоков процессора.
А если программа использует больше тредов, чем есть ядер у компьютера? Вам не стоит беспокоиться: за это отвечает планировщик ОС, который сам распределит ресурсы в угоду производительности.
Как работают потоки в Java
Чтобы увидеть, как работает поток, выполните в режиме отладки следующий код, расставив брейкпоинты на каждой строчке:
Команды будут выполняться последовательно: сначала на экране появится слово Hello, затем пробел, а в конце — World. Это обычное поведение программы, но его можно изменить с помощью класса Thread и интерфейса Runnable из стандартной библиотеки java.lang.
Посмотрим, что покажет программа, если воспользоваться методом Thread.currentThread(), который возвращает ссылку на текущую нить:
В квадратных скобках первым параметром указано имя потока main, о котором говорилось выше. Второй параметр — это приоритет (по умолчанию он равен 5), третий — имя группы потоков.
Отдельно имя можно получить с помощью метода getName():
Как создать свой поток в Java
Для этого есть класс Thread — это поток, только на уровне кода. Создать их можно сколько угодно, но одновременно будет выполняться столько, сколько поддерживает ваша система.
Интерфейс Runnable — это задача, которую выполняет поток, то есть код. Интерфейс содержит основной метод run() — в нём и находится точка входа и логика исполняемого потока.
Создать поток в Java можно двумя способами.
Первый способ:
- Определить класс — наследник класса Thread и переопределить метод run().
- Создать экземпляр своего класса и вызвать метод start().
Обратите внимание: если на экземпляре класса Thread вместо метода start() вызвать run(), то код, написанный для другого потока, отлично выполнится, но выполнит его тот же тред, который и вызвал этот метод, а новый запущен не будет! Поэтому нужно пользоваться методом start().
Второй способ:
- Реализовать интерфейс Runnable и метод run().
- Создать экземпляр Thread и передать в конструктор свой Runnable (экземпляр класса, реализующий этот интерфейс).
Второй вариант лучше — он более гибкий. Например, если бы MyThread уже наследовал какой-либо класс, то было бы невозможно пойти первым путём, так как Java не поддерживает множественное наследование.
Практика: изучаем потоки на котиках
Напишем небольшую консольную игру, в которой будут драться… коты. Обещаем: в процессе написания программы ни один кот не пострадает, зато вы увидите, как «нити» конкурируют между собой.
В коде будут новые ключевые слова и класс, которых вы раньше, скорее всего, не встречали:
- synchronized перед методом означает, что он синхронизирован. Поток, вызвавший синхронизированный метод, запрещает другим нитям к нему обращаться, пока сам не выйдет из метода.
- volatile нужен, когда одну переменную используют разные потоки, во избежание некорректных результатов.
- Класс CopyOnWriteArrayList — это тот же ArrayList, только потокобезопасный, то есть оптимизированный под использование нескольких потоков. Он находится в библиотеке java.util.concurrent.
Программа состоит из двух классов — CatFightsConsole и Cat. Вначале создаётся N боевых «котов», а когда они запускаются, они начинают драться друг с другом. Каждый кот стремится первым вызвать метод Cat.attack(), чтобы атаковать случайного из оставшихся в живых конкурентов и отнять у него жизнь. Когда жизнь кота становится равна 0, его поток завершает свою работу. Бой идёт до последнего кота.
Класс CatFightsConsole содержит метод main(String[] args), в котором производится начальная настройка программы: создание и настройка объектов, запуск потоков. Далее главный поток натыкается на метод join(), который говорит ему остановиться на этой строчке, пока не завершится поток, на котором был вызван метод. Когда все «кототреды» завершат свою работу, в консоль выведется сообщение о последнем коте — победителе.
У класса Cat есть имя (String name), количество жизней (int life), личный поток и статический список cats со ссылками на все объекты Cat. Он реализует интерфейс Runnable, а значит, основной цикл работы потока происходит в методе run().
При запуске потока, пока объектов Cat более одного и пока у них есть жизни, вызывается синхронизированный статический метод Cat.attack(), который декрементирует переменную life с помощью метода decrementLife() у второго переданного в него объекта. Если после этого значение life равно нулю, то у этого же объекта вызывается метод getThread(), а на нём interrupt() — функция, прерывающая работу потока.
Изучите код и запустите его на своём компьютере. Из-за постоянной гонки потоков результаты будут различаться при каждом запуске программы.
Посмотрите, как поведут себя потоки, если дать «котам» 1 или 100 000 жизней. Проверьте, не происходит ли ошибок в вычислениях здоровья и успешно ли завершаются треды.
Также в качестве практики вы можете модернизировать код и поиграться с потоками. Например, добавить новых котов, изменить величину урона или добавить новый класс и подумать, как реализовать механизм их взаимодействия.
Состояния потока и время его жизни
Поток, как и любой другой объект, имеет цикл жизни: он рождается, живёт (готов к выполнению или выполняется), спит (находится в ожидании), и умирает (завершает свою работу).
Получить текущее состояние потока позволяет метод getState(). Он возвращает одно из значений перечисления State, которое содержит набор из 6 констант:
- NEW — новый, только что созданный поток. Это состояние присваивается, когда выделяется память для объекта.
- RUNNABLE — вызывая метод start(), поток становится готовым к выполнению, а затем выполняемым.
- BLOCKED / WAITING / TIME_WAITING — данные состояния означают, что поток находится в ожидании своего выполнения.
- TERMINATED — после завершения работы поток уничтожается.
В следующем примере на экран выводится несколько состояний потока. Внимательно изучите код и комментарии к нему:
Как говорилось ранее, когда запускается новый поток, старый продолжает работу. Вызвав метод join(), главный поток перейдёт на время в состояние WAITING, а затем снова станет RUNNABLE. По завершении работы программы все потоки перейдут в состояние TERMINATED.
Есть ещё метод isAlive(), который позволяет узнать, жив поток или нет. Он возвращает логическое значение true или false.
Что в итоге
В этой статье мы познакомились с понятием многопоточности в Java, прошлись по базовым терминам, узнали, что такое поток, как его создать и в каких состояниях он может пребывать, а также увидели, как работает несколько тредов одновременно, на примере консольного приложения. Повторим основные моменты:
- Потоки — это виртуальные сущности, которые последовательно выполняют код. Они протекают в процессах, где процесс — это программа, которая выполняется.
- Поток можно создать двумя способами: унаследовать класс Thread или реализовать интерфейс Runnable.
- Вся логика нового треда выполняется в методе run(), а запускается он методом start().
- Поток имеет свой жизненный цикл и шесть состояний, описанных в перечислении State.
- State — это свойство класса Thread, которое содержит состояния потока, а получить его можно с помощью метода getState().
- Метод join() переводит в ожидание текущий поток, а interrupt() прерывает его работу.
Многопоточность — важная тема в программировании. Все современные системы используют много потоков для увеличения производительности, а также выполнения нескольких задач одновременно. Поэтому важно понимать, как многопоточность работает.
Читайте также:
Декрементирование — уменьшение величины переменной на единицу. Противоположная операция, инкрементирование, — увеличение на единицу.
[C++] часть 1: многопоточность, конкурентность и параллелизм: ОСНОВЫ
Вначале, когда ещё только состоялось моё знакомство с многопоточностью в C++, многое было мне непонятным и сбивало с толку. Сложность программы расцветала буйным цветом (именно так: подобно прекрасному цветку), конкурентность и параллелизм с их недетерминированным поведением меня просто убивали, и всё было как в тумане. Так что мне легко понять всех приступающих к изучению этих понятий. Спешу избавить вас от мучений и предлагаю вашему вниманию это простое руководство по изучению конкурентности, параллелизма и многопоточности в C++ (в конце данной статьи расписан план, в соответствии с которым мы будем двигаться дальше).
А пока освежим в памяти основные понятия и попробуем на вкус код, выполняемый в многопоточной среде.
1. Что такое поток?
В любом процессе создаётся уникальный поток выполнения, который называется основным потоком. Он может с помощью операционной системы запускать или порождать другие потоки, которые делят то же адресное пространство родительского процесса (сегмент кода, сегмент данных, а также другие ресурсы операционной системы, такие как открытые файлы и сигналы). С другой стороны, у каждого потока есть свой идентификатор потока, стек, набор регистров и счётчик команд. По сути, поток представляет собой легковесный процесс, в котором переключение между потоками происходит быстрее, а взаимодействие между процессами — легче.
2. Что такое конкурентность/параллелизм
Планировщик распределяет процессорное время между разными потоками. Это называется аппаратным параллелизмом или аппаратной конкурентностью (пока что считаем здесь параллелизм и конкурентность синонимами): когда несколько потоков выполняются на разных ядрах параллельно, причём каждый занимается конкретной задачей программы.
→ Примечание: чтобы определить количество задач, которые реально можно выполнять в многопоточном режиме на том или ином компьютере, используется функция std::thread::hardware_concurrency() . Если число потоков будет превышать этот лимит, может начаться настоящая чехарда с переключением задач (когда слишком частые переключения между задачами — много раз в секунду — создают лишь иллюзию многопоточности).
3. Основные операции с потоками с помощью std::thread
- Заголовочный файл| #include <thread>
- Запуск потока| std::thread t(callable_object, arg1, arg2, ..)
Создаёт новый поток выполнения, ассоциируемый с t, который вызывает callable_object(arg1, arg2) . Вызываемый объект (т.е. указатель функции, лямбда-выражение, экземпляр класса с вызовом функции operator ) немедленно выполняется новым потоком с (выборочно) передаваемыми аргументами. Они копируются по умолчанию. Если хотите передать по ссылке, придётся использовать метод warp к аргументу с помощью std::ref(arg) . Не забывайте: если хотите передать unique_ptr, то должны переместить его ( std::move(my_pointer) ), так как его нельзя копировать. - Жизненный цикл потока| t.join() и t.detach()
Если основной поток завершает выполнение, все второстепенные сразу останавливаются без возможности восстановления. Чтобы этого не допустить, у родительского потока имеются два варианта для каждого порождённого:
→ Блокирует и ждёт завершения порождённого потока, вызывая на нём метод join .
→ Прямо объявляет, что порождённый поток может продолжить выполнение даже после завершения родительского, используя метод detach . - Запомните: объект потока можно перенести, но нельзя копировать.
Здесь вы можете найти пример кода, иллюстрирующий практически всё, что написано выше.
4. Зачем нужна синхронизация?
Из-за того, что несколько потоков делят одно адресное пространство и ресурсы, многие операции становятся критичными, и тогда многопоточности требуются примитивы синхронизации. И вот почему:
- Память — дом с привидениями
Память никогда больше не будет обычным хранилищем данных — теперь это обитель привидений. Представьте: поток смотрит Netflix, уютно устроившись перед Smart TV, и тут вдруг экран мигает и выключается. В панике поток набирает 112, а в ответ… «Доставка пиццы, спасибо, что позвонили». Что происходит? А то, что в доме полно привидений (где в роли привидений другие потоки): они все в одной комнате и взаимодействуют с одними и теми же объектами (это называется гонка данных), но друг для друга они привидения.
Поток должен объявить, что он использует. А затем, прежде чем трогать этот объект, проверить, не использует ли его кто-то ещё. Зелёный поток смотрит ТВ? Значит, никто не должен трогать ТВ (другие могут рядышком сесть и посмотреть, если что). Это можно сделать с помощью мьютекса.
- Нужны атомарные операции!
Большинство операций неатомарные. Если операция неатомарная, можно увидеть её промежуточное состояние, так как она не является неделимой. Например: запись 64 битов, 32 бита за один раз. Во время этой операции другой поток может увидеть 32 старых бита и 32 новых, получая совершенно неверный результат. По этой причине результаты таких операций должны казаться атомарными, даже если они такими не являются.
→ Примечание: даже инкремент не является атомарной операцией: int tmp = a; a = tmp + 1;
Самое простое решение здесь — использовать шаблон std::atomic , который разрешает атомарные операции разных типов. - Когерентность кеша и выполнение с изменением очерёдности
Каждое ядро пытается сохранить результаты какой-то работы, помещая недавние значения в локальный кеш. Но несколько потоков выполняются на разных ядрах, и значения, хранящиеся в кеше, больше не могут быть валидными, так что рано или поздно кеш должен обновляться. В то же время изменения не видны другим, пока кеш не очищен. Чтобы распространить изменения и обеспечить корректную видимость памяти, нужны определённые механизмы.
Кроме того, для повышения эффективности процессор и/или компилятор может поменять очерёдность выполнения команд. Это может привести к непредсказуемому поведению в параллельно выполняемой программе, в связи с чем необходимо гарантировать исполнение критически важных команд в первоначальном порядке.
Эта работа выполняется примитивами синхронизации, предполагающими использование барьеров доступа к памяти (строки кода, которые не вычеркнуть какими-то операциями) для обеспечения согласованности и предотвращения изменения очерёдности выполнения (инструкции внутри барьеров памяти нельзя вытащить оттуда).
Пример кода
Обратимся к коду. Теперь вы сами можете проверить это недетерминированное поведение многопоточности.
В отличие от однопоточной реализации, каждое выполнение даёт разный и непредсказуемый результат (единственное, что можно сказать определённо: строки А и B упорядочены по возрастанию). Это может вызвать проблемы, когда очерёдность команд имеет значение.
Но что здесь происходит? После того как поток А оценивает «значение» как истинное, поток B меняет его. Теперь мы внутри блока if , даже если нарушены ограничения.
Если два потока имеют доступ к одним и тем же данным (один к записи, другой — к чтению), нельзя сказать наверняка, какая операция будет выполняться первой.
Доступ должен быть синхронизирован.
Заключение
Вы можете сказать: «Батюшки! Сколько всего намешано в этой статье!» Просто помните, что не надо пытаться понять всё и сразу, важно ухватить основные идеи.
Предлагаю пока что поиграть с примерами и посмотреть, как в них проявляется многопоточность. Можете подумать над другими примерами, где нужна синхронизация, и протестировать их (подсказка: потоки, удаляющие элементы из начала очереди. Не забывайте: прежде чем удалять, надо проверить, не пуста ли очередь).
План статей
В будущих статьях будут освящены следующие темы:
- Теория + Простые примеры
→ Низкоуровневые подходы
1. Мьютекс
2. std::condition_variable
3. Атомарность
→ Высокоуровневые подходы
3. Future и async
4. Промисы
5. std::packeged_task - Практика + самостоятельная работа и упражнения
Библиотека C++11 представляет стандартный механизм для синхронизации, независимый от базовой платформы, так что говорить о потоках, выполняемых в Linux и Windows, мы не будем. Тем более, что основные принципы похожи.
В следующей статье рассмотрим примитив синхронизации мьютекса и как его задействовать по максимуму.