Что такое многопоточные приложения?
Если описать доступным языком - это приложения с несколькими
«рабочими», которые одновременно выполняют разные или однотипные задачи. Зачем
это нужно? Ресурсы компьютера используются не всегда эффективно. Например, ваша
программа скачивает страницу из интернета, потом анализирует ее, затем - качает
следующую. Во время анализа простаивает интернет соединение, а во время закачки
- скучает процессор. Это можно исправить. Уже во время анализа текущей страницы
параллельно качать следующую.
Я попытаюсь объяснить, как оптимизировать свои программы,
используя многопоточность, и приведу пару примеров с кодом.
Для начала, давайте разберемся, когда можно распараллелить
программу. Например, у вас есть один поток данных. Его нужно обрабатывать в
строго определенном порядке и без результатов предыдущей обработки, следующую
операцию выполнять нельзя. В такой программе можно создать дополнительные
потоки, но будут ли они нужны? Мой преподаватель по компьютерным системам
приводил следующий пример.
Допустим, у нас есть 2 рабочих, которые хорошо копают ямы.
Предположим, что один выкопает яму глубиной 2 метра, за 1 час. Тогда два
рабочих, выкопают эту яму за полчаса. Это похоже на правду. Давайте возьмем
3600 таких рабочих. Теоретически, они выкопают яму глубиной 2 метра за 1
секунду. Но на практике они будут друг другу мешать, топтаться на одном месте и
нервничать.
Я надеюсь, вы поняли, что многопоточность нужна не всегда, и
что утверждение «чем больше потоков - тем лучше» ошибочно. Потоки не должны
мешать друг другу и должны использовать эффективно ресурсы системы, в которой
они работают.
Далее немного практики. Что бы начать работать с потоками,
необходимо подключить пространство имен System.Threading, добавив в начало
файла с кодом следующую директиву:
using System.Threading;
Любой поток в C# это функция. Функции не могут быть сами по
себе, они обязательно являются методами класса. Поэтому, что бы создать
отдельный поток, нам понадобится класс с необходимым методом. Самый простой
вариант метода возвращает void и не принимает аргументов:
void
MyThreadFunction() { ... }
Пример запуска такого потока:
Thread thr
= new Thread(MyThreadFunction);
thr.Start();
После вызова метода Start() у объекта потока, управление
вернется сразу, но в этот момент уже начнет работать ваш новый поток. Новый
поток выполнит тело функции MyThreadFunction и завершится. Мой друг спросил
меня, а почему функция не возвращает значение? А потому, что его некуда
вернуть. После вызова Start(), управление передается дальше, при этом созданный
поток может работать еще длительное время. Что бы обмениваться данными между
потоками, можно пользоваться переменными класса. Об этом позже.
Кроме того, существует еще один вариант метода, из которого
можно сделать поток.
Выглядит он вот так:
void
ThreadFunction(Object input) { ... }
Его назначение - применение в тех случаях, когда нужно при
создании потоков, передавать им какие-то данные. Любой класс в C# наследуется
от Object, поэтому можно передавать внутрь потока любой объект. Подробнее
позже.
Давайте напишем простой пример, что бы проиллюстрировать
работу потоков. Реализуем следующее. Наша программа запустит дополнительный
поток, после чего выведет три раза на экран «Это главный поток программы!», в
это время созданный поток выведет три раза на экран «Это дочерний поток
программы!».
Вот, что получилось:
using System;
using System.Threading;
namespace ThreadsExample
{
class Program
{
static void Main(string[] args)
{
//Создаем объект потока
Thread thread = new Thread(ThreadFunction);
//Запускаем поток
thread.Start();
//Просто
выводим 3 раза на экран заданный текст
int count = 3;
while(count 0)
{
Console.WriteLine("Это главный поток программы!");
--count;
}
//Ждем
ввода от пользователя, что бы окно консоли не закрылось автоматически
Console.Read();
}
//Функция потока
static void ThreadFunction()
{
//Аналогично главному потоку выводим три раза текст
int count = 3;
while(count 0)
{
Console.WriteLine("Это дочерний поток программы!");
--count;
}
}
}
}
Ничего сложно. Хочу обратить ваше внимание, что у метода
потока присутствует модификатор static, это нужно для того, что бы к ней можно
было напрямую обратиться из главного потока приложения. Теперь запустите
программу несколько раз и сравните результаты вывода в консоль. Обычно, порядок
вывода сообщений в консоль при каждом запуске разный. Планировщик задач
операционной системы по-разному распределяет процессорное время, поэтому
порядок вывода разный. Еще одно полезное свойство потоков заключается в том,
что вам не нужно беспокоиться о правильности распараллеливания их на
процессоре, планировщик задач все сделает сам.
Что бы создать несколько потоков, необязательно
использовать несколько функций, если потоки одинаковые.
Вот пример:
using System;
using System.Threading;
namespace SomeThreadsExample
{
class Program
{
static void Main(string[] args)
{
//Создаем в
цикле 10 потоков
for(int i = 0; i 10; ++i)
{
Thread thread = new Thread(ThreadFunction);
thread.Start();
}
Console.WriteLine("Создание потоков завершено!");
Console.Read();
}
static void ThreadFunction()
{
//Просто
выводим что-нибудь в консоль для наглядности
Console.WriteLine("Я поток!");
}
}
}
Как видите, для создания 10 потоков нам понадобилась всего 1
функция.
Каждый поток имеет свой стек, поэтому локальные переменные
метода для каждого потока свои. Что бы это продемонстрировать, мы создадим поток для метода класса, а потом
вызовем этот же метод из главного потока.
Вот код:
using System;
using System.Threading;
namespace SomeThreadsExample
{
class Program
{
static void Main(string[] args)
{
//Создаем поток
Thread thread = new Thread(ThreadFunction);
thread.Start();
//Вызываем
этот же метод без создания потока
ThreadFunction();
Console.Read();
}
static void ThreadFunction()
{
int count = 5;
//Выводим пять раз значение count
while(count 0)
{
Console.WriteLine(count);
--count;
}
}
}
}
После завершения выполнения потоков, в консоле будет
выведено 10 чисел.
Если же мы хотим, что бы каждый поток вел себя по-разному,
то есть два решения. Первое - создание дополнительной функции для потока.
Например, вот так:
using System;
using System.Threading;
namespace SomeThreadsExample
{
class Program
{
static void Main(string[] args)
{
//Создаем первый поток
Thread thread1 = new Thread(ThreadFunction1);
thread1.Start();
//Создаем второй поток
Thread thread2 = new Thread(ThreadFunction2);
thread2.Start();
Console.Read();
}
static void ThreadFunction1()
{
//Просто
выводим что-нибудь в консоль для наглядности
Console.WriteLine("Это первый поток!");
}
static void ThreadFunction2()
{
//Аналогично первому потоку
Console.WriteLine("Это второй поток!");
}
}
}
Второе решение более запутанное. Смысл в том, что бы
используя один метод выполнять разные действия в разных потоках. Для этого,
внутри метода потока нужно определить «кто я?». Как вариант, используем метод
потока, который может принимать значения. В поток будем передавать булевый
флаг. Если он равен истине - значит это первый поток, если ложь - значит
второй. Метод потока сам будет определять «кто я?» и в зависимости от этого,
выполнять разные действия.
Вот код:
using System;
using System.Threading;
namespace SomeThreadsExample
{
class Program
{
static void Main(string[] args)
{
Thread thread1 = new Thread(ThreadFunction);
thread1.Start(true);
Thread thread2 = new Thread(ThreadFunction);
thread2.Start(false);
Console.Read();
}
static void ThreadFunction(Object
input)
{
//Преобразовываем входящий параметр в bool
bool flag = (bool)input;
//Если
входящий флаг true - значит "я первый поток"
if(flag)
{
Console.WriteLine("Это первый поток!");
}
//Если
входящий флаг false - значит "я второй поток"
else
{
Console.WriteLine("Это второй поток!");
}
}
}
}
Как работает данная программа? Каждый поток использует свой
экземпляр метода. Если потоку выдали флаг true, значит он выполняет один код,
если false - другой. Этот пример так же наглядно демонстрирует как работать с
методами потоков, который принимают параметры.
Давайте разберем, как обмениваться данными между разными
потоками. Перед тем, как я приведу пример с кодом, расскажу немного теории. Во
всех многопоточных приложениях существует синхронизация данных. Когда два или
больше потоков, пытаются взаимодействовать с какими либо данными одновременно -
может возникнуть ошибка. Для этого в C# существует несколько способов
синхронизации, но мы рассмотрим самый простой с помощью оператора lock.
Теперь давайте пример. Создадим отдельный класс, который
будет создавать дополнительные потоки. Каждый поток будет работать с одной
переменной (свойство класса). Для обеспечения сохранности данных мы будем
использовать оператор lock. Его синтаксис очень простой:
lock(object1) { ... }
Как он работает? Когда один из потоков, доходит до оператора
lock, он проверяет, не заблокирован ли object1. Если нет - выполняет указанные
в скобках операторы. Если заблокирован - ждет, когда object1 будет
разблокирован. Таким образом, оператор lock предотвращает одновременное
обращение нескольких потоков к одним и тем же данным.
А вот и пример программы:
using System;
using System.Threading;
namespace ThreadingClass
{
class Program
{
static void Main(string[] args)
{
//Создаем
объект нашего класса
Worker
worker = new Worker();
//Запускаем
создание потоков
worker.Run();
//Ждем
ввода от пользователя, что бы не закрылась консоль
Console.Read();
}
}
//Класс, который
создает потоки
class Worker
{
//Переменная
для демонстрации работы оператора lock
private int
value = 0;
//Переменная
"локер", которая служит для блокировки value
private object valueLocker = new object();
//Метод запускающий потоки
public void Run()
{
for(int i = 0; i 5; ++i)
{
Thread thread = new Thread(ThreadFunction);
thread.Start();
}
}
private void ThreadFunction()
{
//Блокируем доступ к локеру
lock(valueLocker)
{
//Выводимзначение value
Console.WriteLine(value);
//Увеличиваем его
на единицу
++value;
}
}
}
}
Я использовал отдельную переменную, для оператора lock,
поскольку он не может заблокировать доступ к int переменной. А вообще, мне как
то на хабре посоветовали всегда использовать «локеры» для блокировки других
данных.
Теперь о программе. Каждый поток сначала выводит значение
свойства value, а потом увеличивает его на единицу. Зачем же тут нужен оператор
lock? Запустите программу. Она выведет по порядку «0 1 2 3 4?. Потоки по
очереди выводят значение value, и все хорошо. Предположим, что оператора lock
нету. Тогда может получиться следующее:
1. Первый поток
выведет 0 на экран;
2. Второй поток
выведет 0 на экран и увеличит значение на единицу;
3. Третий поток
выведет 1на экран;
4. Первый поток
увеличит значение на единицу;
5. Третий поток
увеличит значение на единицу;
6. Второй поток
выведет 3 на экран;
… и так далее. В результате мы получим, что-то вроде «0 0 1
3 4?. Это связано с тем, что процессор и планировщик задач не знают в каком
порядке выполняться операции, поэтому они делают это оптимально с точки зрения
выполнения программы, при этом логика нарушается.