Thread Pool 2 (Parte 2 - Cola circular)

        Al considerar cómo diseñar un contenedor de tareas, en realidad lo intenté mucho. Al principio, usé el contenedor std::queue directamente, principalmente porque leí la publicación "Grupo de subprocesos  basado en C++ 11 - Zhihu  " en Zhihu para encapsular una cola segura. Sin embargo, esta operación debe bloquearse una vez cada vez, lo que es una pérdida de tiempo.Significa que cada vez que se accede a la cola de tareas, todos los subprocesos deben detenerse y esperar a que se complete la cola de tareas antes de continuar con la ejecución. Es equivalente a volver de subprocesos múltiples a subprocesos únicos.

        Entonces, el problema central es que la biblioteca STL original no es segura para subprocesos. Por lo tanto, el requisito actual es diseñar una estructura de datos usted mismo, sin bloqueos y segura para subprocesos.

        Entonces la primera reacción es la operación atómica de c++11.

        El bloqueo atómico de la operación atómica es una instrucción a nivel de hardware, que es mucho más rápida que mutex. De hecho, en el grupo de subprocesos, hay muy pocas variables de memoria que necesitamos proteger, y no es necesario usar mutex para proteger toda la estructura de datos y la lógica.


        bien. Ahora que existe una solución para la seguridad de subprocesos, está el problema de la estructura de datos, qué estructura de datos usar. Bueno, ve directamente a ChatGPT.

Una muy buena idea, la cola circular, el tipo de datos internos es una función.


Primero diseñe una clase para imitar la implementación de vector en la biblioteca estándar, tres punteros, una cabeza, una cola y una capacidad máxima.

template <typename T>
class BoundedQueue
{
public:
	using value_type = T;
	using size_type = uint64_t;

public:
	// 禁用 拷贝赋值运算符 和拷贝构造函数
	BoundedQueue& operator= (const BoundedQueue& other) = delete;
	BoundedQueue(const BoundedQueue& other) = delete;

	BoundedQueue() {}
	~BoundedQueue();
	void BreakAllWait();

	// 初始化   设定队列大小    和   等待策略
	bool Init(uint64_t size);
	bool Init(uint64_t size, WaitStrategy* strategy);

	// 入队操作
	bool Enqueue(const T& element);
	// 等待入队操作,当队列满时等待
	bool WaitEnqueue(const T& elemen);

	// 出队
	bool Dequeue(T* element);
	// 等待出队,当队列为空时,等待出队
	bool WaitDequeue(T* element);


private:
	// 索引,队里索引下标
	uint64_t GetIndex(uint64_t num);
    设定内存对齐方式
	alignas(64) std::atomic<uint64_t> head_ = { 0 };	// 头
	alignas(64) std::atomic<uint64_t> tail_ = { 1 };	// 尾
	alignas(64) std::atomic<uint64_t> commit_ = { 1 };	// 最大容量

	uint64_t pool_size_ = 0;		// 队列大小
	T* pool_ = nullptr;		// 当前任务队列的内存指针
	std::unique_ptr<WaitStrategy> wait_strategy_ = nullptr;

	volatile bool break_all_wait_ = false;
};

1. El primero es evitar que el compilador genere automáticamente constructores de copias predeterminados y funciones de sobrecarga de operadores de asignación. Al diseñar una clase, debemos considerar si el comportamiento predeterminado del compilador es necesario para nosotros. Prohíbalo si no lo necesita.

        Esta cola circular solo puede existir en el grupo de subprocesos, el acceso externo está prohibido y la cola de tareas no puede copiarse en el grupo de subprocesos. Así que debería estar prohibido.

// 禁用 拷贝赋值运算符 和拷贝构造函数
	BoundedQueue& operator= (const BoundedQueue& other) = delete;
	BoundedQueue(const BoundedQueue& other) = delete;

2. Las siguientes son algunas acciones que deben usarse, inicialización, poner en cola, quitar de la cola, la cola de tareas está llena y esperando para poner en cola, la cola de tareas está vacía, esperando para quitar de la cola.

public:
	// 禁用 拷贝赋值运算符 和拷贝构造函数
	BoundedQueue& operator= (const BoundedQueue& other) = delete;
	BoundedQueue(const BoundedQueue& other) = delete;

	BoundedQueue() {}
	~BoundedQueue();
	void BreakAllWait();

	// 初始化   设定队列大小    和   等待策略
	bool Init(uint64_t size);
	bool Init(uint64_t size, WaitStrategy* strategy);

	// 入队操作
	bool Enqueue(const T& element);
	// 等待入队操作,当队列满时等待
	bool WaitEnqueue(const T& elemen);

	// 出队
	bool Dequeue(T* element);
	// 等待出队,当队列为空时,等待出队
	bool WaitDequeue(T* element);

3, y luego el más crítico

private:
    // 获取索引,队里索引下标
	uint64_t GetIndex(uint64_t num);

	alignas(64) std::atomic<uint64_t> head_ = { 0 };	// 头
	alignas(64) std::atomic<uint64_t> tail_ = { 1 };	// 尾
	alignas(64) std::atomic<uint64_t> commit_ = { 1 };	// 最大容量

	uint64_t pool_size_ = 0;		// 队列大小
	T* pool_ = nullptr;		// 当前线程池的内存指针
	std::unique_ptr<WaitStrategy> wait_strategy_ = nullptr;
	// volatile 关键字告诉编译器在访问该变量时不进行优化,每次都从内存中读取变量的值。
	// 是否中断所有的等待操作
	volatile bool break_all_wait_ = false;

        Los tres punteros aquí se implementan mediante clases atómicas y están alineados en 64 bits. De hecho, cuando lo escribí por primera vez, en realidad no escribí la lógica de la alineación de la memoria, así que usé la clase atómica directamente. Pero después de hacer pruebas de estrés en todo el proyecto y luego ver dónde se puede optimizar para reducir el tiempo, descubrí que la parte que más tiempo consume de todo el grupo de subprocesos es en realidad sacar tareas de la cola de tareas.

        Luego agregamos un temporizador al operar la cola de tareas y luego lo imprimimos. Se encuentra que el tiempo de acceso de las primeras decenas de tareas es de 0.038s, luego pasa a ser de 0.121s y siempre ha estado en este nivel.De repente, el aumento de este tiempo en más de 3 veces no es normal. Luego mire el ensamblaje, solo una instrucción de movimiento, no debería ser tan lento. El operando de origen es una dirección de memoria, y el operando de destino es un registro. Copie datos de una dirección de memoria a un registro, y estos datos de memoria deben almacenarse en caché. ¿Por qué es tan lento?

        Después de verificar un montón de información, descubrí que algo anda mal. ¿Hay un falso intercambio de memoria?

        Cuando el subproceso A obtiene una tarea, los valores de estos 3 punteros se modifican y, al mismo tiempo, estos 3 valores en el subproceso B también se modifican. Aunque el subproceso B no se preocupó por los valores de estos tres punteros en ese momento, la CPU recargó los datos actualizados de la memoria.

        ¡Se ha producido un falso intercambio de hilos! !

        Hilo pseudo-compartido:

        Si la CPU tiene solo un núcleo, en la programación de subprocesos múltiples, cuando cada subproceso cambia, es necesario guardar el contexto del subproceso actual y luego cargar el contexto del subproceso para que se ejecute la próxima vez, lo que se denomina cambio de contexto .

        Las CPU modernas generalmente tienen múltiples núcleos, por lo que habrá múltiples subprocesos ejecutándose en paralelo durante la operación real, y cada núcleo tiene un caché independiente.En circunstancias normales, si los dos subprocesos que se ejecutan en paralelo no tienen acceso o si se modifica la misma memoria, esto no sucederá. afectarse unos a otros. Sin embargo, debido a la existencia de la línea de caché, si un subproceso modifica ciertos datos en la línea de caché del subproceso que se ejecuta en otro núcleo, entonces la CPU necesita recargar el caché en este momento con los datos en línea.

        Para resolver este problema, se utiliza la alineación de memoria. 

        (Algunos de nosotros hemos estado revisando esta pregunta durante casi una semana... realmente inexpertos...)

alignas(64) std::atomic<uint64_t> head_ = { 0 };	// 头
alignas(64) std::atomic<uint64_t> tail_ = { 1 };	// 尾
alignas(64) std::atomic<uint64_t> commit_ = { 1 };	// 最大容量

pd: Pero para ser honesto, si no fuera por la prueba de presión, no sería posible encontrar este problema. Pero este lugar realmente hizo que todo el proceso fuera más rápido entre 0,3 s y 0,7 s. Se puede considerar como una optimización exitosa.


Luego está la implementación de cada acción. Hablemos en grupos.

1. Fuera del equipo

// 出队
template<typename T>
bool BoundedQueue<T>::Dequeue(T* element) {
	uint64_t new_head = 0;
	uint64_t old_head = head_.load(std::memory_order_acquire);
	do
	{
		// 计算新值
		new_head = old_head + 1;

		// 如果头位置等于了最大容量  出队失败   队列中没有元素可以取出 new_head == commit_
		if (new_head == commit_.load(std::memory_order_acquire))
			return false;

		// 将要取出的元素复制到 element指向的内存中
		*element = pool_[GetIndex(new_head)];

	} while (!head_.compare_exchange_weak(old_head, new_head,
                         std::memory_order_acq_rel,  std::memory_order_relaxed));
	return true;
}

// 等待出队,当队列为空时,等待出队
template<typename T>
bool BoundedQueue<T>::WaitDequeue(T* element) {
	while (!break_all_wait_) {
		if (Dequeue(element))
			return true;
		
		if (wait_strategy_->EmptyWait()) {
			continue;
		}
		break;
	}
	return false;
}

El método head-out se utiliza para eliminar la cola. Utilizando las características de las clases atómicas, se diseña un algoritmo sin bloqueo para garantizar la seguridad de subprocesos en un entorno de subprocesos múltiples. Todas las búsquedas son operaciones atómicas.

La lógica de esperar a ser eliminado es:

        Si la eliminación normal de la cola falla, ingrese la lógica de espera, espere hasta que finalice la lógica de espera y vuelva a intentarlo.


2. Únete al equipo.

/*
	入队
*/
template<typename T>
bool BoundedQueue<T>::Enqueue(const T& element) {
	uint64_t new_tail = 0;	//	更新后的队尾
	uint64_t old_commit = 0;	//	原最大容量

	// 获取旧值
	uint64_t old_tail = tail_.load(std::memory_order_acquire);

	do
	{
		// 计算新值
		new_tail = old_tail + 1;

		// 若新值等于了原头head_位置,则表示队列已满,插入失败
		if (GetIndex(new_tail) == GetIndex(head_.load(std::memory_order_acquire))) {
			return false;
		}

		// 进行tail_值 比较和替换,如果tail_ == old_tail,则使用new_tail作为新值,并且重新作为尾结点
		// 就是{ if (head_ == old_head) head_ = new_head; } 的原子操作。
	} while (!tail_.compare_exchange_weak(old_tail, new_tail,
										std::memory_order_acq_rel, 
										std::memory_order_relaxed));

	// 将新元素插入到队尾,移动到相应为止
	pool_[GetIndex(old_tail)] = element;

	do
	{
		// 保证最大容量commit_和tail_的值一样
		old_commit = old_tail;

		// 交换成功结束循环   
	} while (cyber_unlikely(!commit_.compare_exchange_weak(old_commit, new_tail,
															std::memory_order_acq_rel, 
															std::memory_order_relaxed)));
	// 通知有可能正在等待的线程获取任务
	wait_strategy_->Notifyone();
	return true;

}


/*
	等待入队
*/
template <typename T>
bool BoundedQueue<T>::WaitEnqueue(const T& element) {
	while (!break_all_wait_) {
		
		// 尝试插入		插入失败则进行等待
		if (Enqueue(element)) {
			return true;
		}

		/*
			队列已满,开始等待策略
		*/
		if (wait_strategy_->EmptyWait()) {
			continue;
		}
		break;
	}
	return false;
}

Método de inserción de la cola.

Es básicamente un giro de la lógica de puesta en cola.


3. Devolver subíndice

template<typename T>
inline uint64_t BoundedQueue<T>::GetIndex(uint64_t num) {
	return num - (num / pool_size_) * pool_size_;  
}

La fórmula de cálculo del subíndice aquí es: número de tarea - (número de tarea - tamaño del grupo de tareas) × tamaño del grupo de tareas

De hecho, puede usar directamente % módulo en su lugar, pero esta función de retorno de subíndice también es una función de alta frecuencia, y la división es más rápida que la operación de módulo, por lo que se usa toda la división en lugar de módulo.

(De hecho, se resolvió incidentalmente al resolver el pseudo-compartir subprocesos aquí... En ese momento, pensé que ese era el problema aquí. Se cambió un poco mejor).


4. Inicialización

template<typename T>
inline bool BoundedQueue<T>::Init(uint64_t size) {
	return Init(size, new SleepWaitStratrgy());   // 若不指定等待策略,默认使用睡眠等待策略
}
/*
std::calloc 分配内存来存储元素并且进行初始化
*/
template<typename T>
bool BoundedQueue<T>::Init(uint64_t size, WaitStrategy* strategy){
	
	// 保证队列两端各留有一个空闲位置
	pool_size_ = size + 2;

	// 在动态内存中分配一片连续的内存空间,将分配的内存空间的起始位置存储在 pool_ 变量中,
	// 以便后续在线程池中使用。
	pool_ = reinterpret_cast<T*>(std::calloc(pool_size_, sizeof(T)));
	if (pool_ == nullptr)
		return false;

	// 遍历线程池的每个元素,并通过 new 在对应的内存位置上构造一个类型为 T 的对象
	for (uint64_t i = 0; i < pool_size_; ++i)
	{
		new(&(pool_[i])) T();
	}

	// 设定等待策略
	wait_strategy_.reset(strategy);

	return true;

} 

Se han diseñado dos métodos de inicialización: si no se especifica la estrategia de espera, se utiliza la estrategia de suspensión de subprocesos de forma predeterminada.


5. Subproceso de activación

template<typename T>
inline void BoundedQueue<T>::BreakAllWait() {
	break_all_wait_ = true;
	wait_strategy_->BreakAllwait();
}

Simplemente llame al subproceso en la estrategia de espera directamente para despertar.


6. Desestructuración

// 析构
template<typename T>
BoundedQueue<T>::~BoundedQueue() {
	if (wait_strategy_) {
		BreakAllWait();
	}

	if (pool_) {
		for (uint64_t i = 0; i < pool_size_; i++)
		{
			pool_[i].~T();
		}
		std::free(pool_);
	}
}

Hasta ahora, la cola de tareas está completa y el siguiente paso es el diseño del grupo de subprocesos. De hecho, no existen muchos diseños de thread pools, lo más difícil es el envío de tareas, ya que para asegurar el análisis de múltiples parámetros se debe utilizar la clase de derivación automática de C++. No hay muchos códigos, pero es un poco complicado de entender. Vamos a resumir en el siguiente artículo.

 Referencia: Implementación de grupo de subprocesos basado en C++ 11 - Saber casi

Supongo que te gusta

Origin blog.csdn.net/qq_35326529/article/details/130891310
Recomendado
Clasificación