Многопоточные приложения в C# для чайников

Потоки(treads - нити) в C#Что такое многопоточные приложения? Грубо говоря, это приложения с несколькими «рабочими», которые одновременно выполняют разные или однотипные задачи. Зачем это нужно? Ресурсы компьютера используются не всегда эффективно. Например, ваша программа скачивает страницу из интернета, потом анализирует ее, затем — качает следующую. Во время анализа простаивает интернет соединение, а во время закачки — скучает процессор. Это можно исправить. Уже во время анализа текущей страницы параллельно качать следующую.

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

Для начала, давайте разберемся, когда можно распараллелить программу. Например, у вас есть один поток данных. Его нужно обрабатывать в строго определенном порядке и без результатов предыдущей обработки, следующую операцию выполнять нельзя. В такой программе можно создать дополнительные потоки, но будут ли они нужны? Мой преподаватель по компьютерным системам приводил следующий пример.

Допустим, у нас есть 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». Это связано с тем, что процессор и планировщик задач не знают в каком порядке выполняться операции, поэтому они делают это оптимально с точки зрения выполнения программы, при этом логика нарушается.

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

Комментарии 41

  • Премного благодарен! Когда читал о потоках на MSDN, ни черта не понял, а это, оказывается,настолько легко…

  • спасибо за статью ) для старта именно то ))))

  • Хорошо для новичка расписано, спасибо! Только по больше бы вариантов того, как вернуть что либо из результата работы потока.

  • весьма доступно и понятно , огромное спасибо ! :3

  • Расскажите, пожалуйста, как распараллелить цикл.
    Например перемножение матриц.

  • Или, например, рекурсивные функции)

  • Очень полезная статья, особенно для новичков. Подскажите, как можно сделать многопоточный калькулятор, чтобы каждое вычисление происходило в отдельном потоке в порядке приоритетности операций ( * -> / -> — -> +)и т.д, и выводился общий вычислений.

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

    Смотрите, выражение А * B + C = X. Две операции: умножения и сложения. Вы не можете одновременно умножить и сложить, так как для сложения требуется результат операции умножения.

    Другое дело, выражения с равноправными частями. Например, A * B + C * D = X. В данном случае, можно одновременно умножить A на B и C на D, а уже после сложить результаты.

    Задавай-те вопросы, может получится ответить.

  • А можно ли как вариант, создавать для каждой операции свой поток, в котором будут использоваться результаты полученные из предидушего потока.

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

  • Или для распределённых вычислении потоки не предназначены?

  • Как запустить поток из другого класса?

    • Зависит от того, что вы конкретно имеете ввиду. У вас функция потока в одном классе, а запустить на выполнение вы хотите из другого?

      • Есть desktop-е приложение:

        класс
        public partial class Form1 : Form

        событие
        private void buttonStart_Click(object sender, EventArgs e)

        есть класс
        public class MyThread : Thread
        {
        bool started = false;

        public void run()
        {
        while (true)
        {
        mainLoopStep();
        Thread.Sleep(1000);
        }
        }

        public void startT()
        {
        started = true;
        }

        public void stopT()
        {
        started = false;
        }
        }

        и вот теперь необходимо каким-то образом из класса Form1 запустить поток из класса MyThread, но при этом необходимо что бы логи программы была реализована в классе Form1 что то типа:
        th = new Thread()
        {
        public void mainLoopStep()
        {
        count++;
        }
        }

        Подскажите пожалуйста как это можно реализовать.

        • Буду честен. Нет желания разбираться в вашем коде. По поводу логов. Почитайте по делегаты в интернете.

  • Спасибо!

  • Автору респект. Долго не мог понять кто эти потоки такие, что из себя представляют. Но автор разложил по полочкам))

  • а можно ли запустить например 1 поток для каждого процессора.
    и вообще где именно они работают в Вашем примере? — на первом ядре или как….

  • Почему у вас «&lt» вместо знаков равенства?

    • Сломалась верстка походу.

      Кстати, зацените мой блог на английском, ссылка в шапке сайта.

  • Спасибо большое, очень помогло на экзамене!))

  • Спасибо большое, я новичек в c#, 2 дня не мог понять синтаксис и работу потоков, пока не нашел вашу статью, все разжовано и предельно ясно, спасибо вам огромное!

  • Добрый день. Так кака же все таки передавать и получать данные из потоков. Опишу ситуацию. Есть программа windows form где пользователь в текстовом поле вводит url сайта. Мне нужно передать этот URL в фоновый поток, который получит исходный код, распарсит необходимые теги, и вернет эти данные в галвный поток дя вывода в пользовательский интерфейс… Честное слово уже месяца два не могу этот момент раздуплить.

    • Твой вопрос достаточно обширный. Конкретизируй.

      Кстати, зацените мой блог на английском, ссылка в шапке сайта.

  • Классно!!!

  • так а как обмениваться данными между потоками то? здесь просто по порядку выводятся данные. Как обмениваться то? в Java есть специальный класс для этого: Exchange()

    • Смотри.

      Зависит от того какие данные ты хочешь гонять туда сюда и от контекста задачи.

      К сожалению я статью писал давно, и сейчас в голове у меня уже нет свежей мысли. Кроме того, давно не писал на C# и могу забыв или не зная нюанс — посоветовать что-то не то.

      Но, всеже приведу пример.

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

      Еще один пример.

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

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

  • Отличная статья. Жду продолжения

    • Рад был помочь!

      Кстати, зацените мой блог на английском, ссылка в шапке сайта.

Добавить комментарий

Ваш адрес email не будет опубликован.