Передача аргументов с использованием шаблона Variadic

Шаблоны Variadic позволяют определять объекты-функции, которые принимают различное количество объектов. Это очень удобно в приложениях с параллелизмом, поскольку мы можем использовать их для передачи нескольких аргументов в поток.

В части этой серии статей о параллелизме мы видели, что один из способов передать аргументы функции потока — это упаковать их в класс с помощью оператора вызова функции. Несмотря на то, что это работало хорошо, было бы очень обременительно писать специальный класс каждый раз, когда нам нужно передать данные в поток. Мы также можем использовать Lambda, которая захватывает аргументы, а затем вызывает функцию. Но есть более простой способ: конструктор потока можно вызвать с помощью функции и всех ее аргументов. Это возможно, потому что конструктор потока представляет собой вариативный шаблон, который принимает несколько аргументов.

До C++11 классы и функции могли принимать только фиксированное количество аргументов, которые нужно было указывать при первом объявлении. С вариативными шаблонами можно включать любое количество аргументов любого типа.

#include <iostream>
#include <thread>
#include <string>

void printID(int id)
{
    std::this_thread::sleep_for(std::chrono::milliseconds(50));
    std::cout << "ID = " << id << std::endl;
    
}

void printIDAndName(int id, std::string name)
{
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout << "ID = " << id << ", name = " << name << std::endl;
}

int main()
{
    int id = 0; // Define an integer variable

    // starting threads using variadic templates
    std::thread t1(printID, id);
    std::thread t2(printIDAndName, ++id, "MyString");
    std::thread t3(printIDAndName, ++id); // this procudes a compiler error

    // wait for threads before returning
    t1.join();
    t2.join();
    //t3.join();


    return 0;
}

Как видно из приведенного выше примера кода, первый объект потока создается путем передачи ему функции printID и целочисленного аргумента. Затем создается второй объект потока с функцией printIDAndName, для которой требуется целое число и строковый параметр. Если бы потоку был предоставлен только один аргумент при вызове printIDAndName, возникла бы ошибка компилятора (см. std::thread t3 в примере) — это та же проверка типов, которую мы получили бы при непосредственном вызове функции.

Есть еще одно различие между прямым вызовом функции и передачей ее потоку: в первом случае аргументы могут передаваться по значению, по ссылке или с использованием семантики перемещения — в зависимости от сигнатуры функции. При вызове функции с использованием вариативного шаблона аргументы по умолчанию либо перемещаются, либо копируются — в зависимости от того, являются ли они rvalue или lvalue. Однако есть способы, которые позволяют нам перезаписать это поведение. Например, если вы хотите переместить lvalue, мы можем вызвать std::move. В следующем примере запускаются два потока, каждый из которых имеет свою строку в качестве параметра. С t1 строка name1 копируется по значению, что позволяет нам печатать name1 даже после вызова join. Вторая строка name2 передается функции потока с использованием семантики перемещения, что означает, что она больше недоступна после вызова join в t2.

#include <iostream>
#include <thread>
#include <string>

void printName(std::string name, int waitTime)
{
    std::this_thread::sleep_for(std::chrono::milliseconds(waitTime));
    std::cout << "Name (from Thread) = " << name << std::endl;
}

int main()
{
    std::string name1 = "MyThread1";
    std::string name2 = "MyThread2";

    // starting threads using value-copy and move semantics 
    std::thread t1(printName, name1, 50);
    std::thread t2(printName, std::move(name2), 100);

    // wait for threads before returning
    t1.join();
    t2.join();

    // print name from main
    std::cout << "Name (from Main) = " << name1 << std::endl;
    std::cout << "Name (from Main) = " << name2 << std::endl;

    return 0;
}

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

#include <iostream>
#include <thread>
#include <string>

void printName(std::string &name, int waitTime)
{
    std::this_thread::sleep_for(std::chrono::milliseconds(waitTime));
    name += " (from Thread)";
    std::cout << name << std::endl;
}

int main()
{
    std::string name("MyThread");

    // starting thread
    std::thread t(printName, std::ref(name), 50);

    // wait for thread before returning
    t.join();

    // print name from main
    name += " (from Main)";
    std::cout << name << std::endl;

    return 0;
}

При передаче имени строковой переменной в функцию потока нам нужно явно пометить его как ссылку, чтобы компилятор воспринял его как таковое. Это можно сделать с помощью функции std::ref. В выводе консоли становится ясно, что строка была успешно изменена в функции потока перед передачей в main.

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

Запуск потоков с функциями-членами

В предыдущих разделах вы видели, как запускать потоки с функциями и функциональными объектами, с дополнительными аргументами и без них. Кроме того, теперь вы знаете, как передавать аргументы функции потока по ссылке. Но что, если мы хотим запустить функцию-член, отличную от оператора вызова функции, например функцию-член существующего объекта? К счастью, библиотека C++ может справиться с этим вариантом использования: для вызова функций-членов функция std::thread требует дополнительного аргумента для объекта, для которого вызывается функция-член.

#include <iostream>
#include <thread>

class Vehicle
{
public:
    Vehicle() : _id(0) {}
    void addID(int id) { _id = id; }
    void printID()
    {
        std::cout << "Vehicle ID=" << _id << std::endl;
    }

private:
    int _id;
};

int main()
{
    // create thread
    Vehicle v1, v2;
    std::thread t1 = std::thread(&Vehicle::addID, v1, 1); // call member function on object v
    std::thread t2 = std::thread(&Vehicle::addID, &v2, 2); // call member function on object v

    // wait for thread to finish
    t1.join();
    t2.join();

    // print Vehicle id
    v1.printID();
    v2.printID();

    return 0;
}

В приведенном выше примере объект Vehicle v1 передается в функцию потока по значению, таким образом создается копия, которая не влияет на «оригинал», живущий в основном потоке. Таким образом, изменения в его переменной-члене _id не будут отображаться при печати с вызовом printID() позже в main. Вместо этого второй Vehicle объект v2 передается по ссылке. Поэтому изменения в его переменной _id также будут видны в потоке main.

В предыдущем примере мы должны гарантировать, что существование v2 переживет завершение потока t2, иначе будет попытка доступа к недействительному адресу памяти. Альтернативой является использование объекта, размещенного в куче, и указателя с подсчетом ссылок, такого как std::shared_ptr<Vehicle>, чтобы гарантировать, что объект живет до тех пор, пока поток завершает свою работу. В следующем примере показано, как это можно реализовать:

#include <iostream>
#include <thread>

class Vehicle
{
public:
    Vehicle() : _id(0) {}
    void addID(int id) { _id = id; }
    void printID()
    {
        std::cout << "Vehicle ID=" << _id << std::endl;
    }

private:
    int _id;
};

int main()
{
    // create thread
    std::shared_ptr<Vehicle> v(new Vehicle);
    std::thread t = std::thread(&Vehicle::addID, v, 1); // call member function on object v
    
    // wait for thread to finish
    t.join();
    
    // print Vehicle id
    v->printID();
    
    return 0;
}