Matrices circulares, una forma de liberar el poder de las colas sin bloqueos

Este artículo se comparte desde la comunidad de la nube de Huawei " Desbloquear el poder de las colas sin bloqueo: exploración del uso de matrices circulares para implementar colas sin bloqueo " por Lion Long.

I. Introducción

En informática, una cola es una estructura de datos común que se utiliza para la entrega eficiente de mensajes y la programación de tareas en un entorno multiproceso o multiproceso. Sin embargo, las implementaciones de colas tradicionales suelen utilizar bloqueos para proteger los recursos compartidos, lo que puede provocar cuellos de botella en el rendimiento y problemas de escalabilidad.

Para superar estas limitaciones, surgieron las colas sin candados. Las colas sin bloqueos utilizan algoritmos y estructuras de datos especiales para permitir que varios subprocesos accedan a la cola simultáneamente sin utilizar bloqueos para proteger los recursos compartidos. Entre ellos, la cola sin bloqueo basada en una matriz circular es un método de implementación clásico.

Este artículo profundizará en los principios y ventajas de las colas sin bloqueo basadas en matrices circulares. Introduciremos los conceptos básicos de las matrices circulares y explicaremos cómo lograr la liberación de bloqueos mediante algoritmos y técnicas apropiados. Al comparar las colas tradicionales protegidas con bloqueos con las colas sin bloqueos, revelaremos las mejoras de rendimiento y las ventajas de escalabilidad de las colas sin bloqueos.

Además, exploraremos los desafíos prácticos y las consideraciones para las colas sin bloqueo basadas en matrices circulares. Compartiremos algunos casos prácticos y lecciones aprendidas para ayudar a los lectores a comprender y aplicar mejor las colas sin bloqueo.

Al leer este artículo, obtendrá una comprensión profunda del poder y el potencial de las colas sin bloqueo basadas en matrices circulares y cómo se pueden utilizar para mejorar el rendimiento y la escalabilidad del sistema. Si es diseñador, desarrollador o investigador de sistemas interesado en la programación concurrente, este artículo le proporcionará ideas valiosas e inspiración.

2. Diseño: interfaz de clase y variables.

#ifndef _ARRAYLOCKFREEQUEUE_H___ 
#define _ARRAYLOCKFREEQUEUE_H___ 

#include <stdint.h> 

#ifdef _WIN64 
#define QUEUE_INT int64_t 
#else 
#define QUEUE_INT unsigned long 
#endif 

#define ARRAY_LOCK_FREE_Q_DEFAULT_SIZE 65535 // 2^ 16 

plantilla <typename ELEM_T, QUEUE_INT Q_SIZE = ARRAY_LOCK_FREE_Q_DEFAULT_SIZE> 
clase ArrayLockFreeQueue 
{ 
public: 

	ArrayLockFreeQueue(); 
	virtual ~ArrayLockFreeQueue(); 

	QUEUE_INT size(); 

	bool enqueue(const ELEM_T &a_data);//poner en cola 

	bool dequeue(ELEM_T &a_data);//dequeue 

    bool try_dequeue(ELEM_T &a_data);// Intenta unirse a la cola 

privada: 

	ELEM_T m_thequeue[Q_SIZE]; 

	volatile QUEUE_INT m_count;//El número de elementos en la cola 
	volatile QUEUE_INT m_writeIndex;//El subíndice de la posición en la matriz cuando se agrega el nuevo elemento a la cola 

	volatile QUEUE_INT m_readIndex ;//El siguiente sale El subíndice del elemento de la cola en la matriz 

	volatile QUEUE_INT m_maximumReadIndex; // El subíndice del último elemento que ha completado la operación de la cola en la matriz en línea 

	QUEUE_INT countToIndex(QUEUE_INT a_count); 
}; 

#include "ArrayLockFreeQueueImp.h" 

#endif

m_maximumReadIndex:  el índice del último elemento de la matriz que ha completado la operación de puesta en cola. Si su valor no coincide con m_writeIndex, indica que no se ha completado una solicitud de escritura. Esto significa que una solicitud de escritura solicitó espacio con éxito pero los datos no se han escrito completamente en la cola. Entonces, si un hilo quiere leer, debe esperar hasta que el hilo de escritura escriba completamente los datos en la cola.

Cabe señalar que es necesario utilizar 3 subíndices diferentes porque la cola permite que cualquier número de productores y consumidores solucionen esta situación.

Gráfico de anillos de matriz:

3. Uso de CAS

Utilice el syn_bool_compare_and_swap integrado de gcc, pero redefina la encapsulación de macros.

#ifndef _ATOM_OPT_H___ 
#define _ATOM_OPT_H___ 

#ifdef __GNUC__ 
	#define CAS(a_ptr, a_oldVal, a_newVal) __sync_bool_compare_and_swap(a_ptr, a_oldVal, a_newVal) #define AtomicAdd(a_ptr,a_count) 
	__sync_fetch_and_add (a_ptr, a _count) 
	#define AtomicSub(a_ptr,a_count) __sync_fetch_and_sub (a_ptr, a_count) 
	#include <sched.h> // sched_yield() 
#else 

#include <Windows.h> 
#ifdef _WIN64 
	#define CAS(a_ptr, a_oldVal, a_newVal) (a_oldVal == InterlockedCompareExchange64(a_ptr, a_newVal, a_oldVal )) 
	#define sched_yield() SwitchToThread() 
	#define AtomicAdd(a_ptr, num) InterlockedIncrement64(a_ptr) 
	#define AtomicSub(a_ptr, num) InterlockedDecrement64(a_ptr) 
#else 
	#define CAS(a_ptr, a_oldVal, a_newVal) (a_oldVal == InterlockedCompareExchange(a_ptr, a_newVal, a_oldVal)) 
	#define sched_yield() SwitchToThread() 
	#define AtomicAdd(a_ptr, num) InterlockedIncrement(a_ptr) 
	#define AtomicSub(a_ptr, num) InterlockedDecrement(a_ptr) 
#endif 

#endif 

#endif

4. Ilustración: Implementación de Cola

4.1, poner en cola en la cola

Plantilla <Typename Elem_t, Queue_int Q_SIZE> 
Inline Queue_int ArrayFreequeue <Elem_T, Q__SIZE> :: CounttoINDEX (queue_int a_count) 
{ 
	RetURN (A_CO indan % q_size); // Al tomar el resto 
} 

Plantilla <Typename Elem_T, Queue_int Q_SIZE> 
BOOL ARRAYLOCKFREEQUEUE < Elem_t, Q_SIZE>::enqueue(const ELEM_T &a_data) 
{ 
	QUEUE_INT currentWriteIndex; // Obtener la posición del puntero de escritura 
	QUEUE_INT currentReadIndex; 
	// 1. Obtener la posición de escritura 
	do 
	{ 
		currentWriteIndex = m_writeIndex; 
		currentReadIndex = m_readIndex; 
		if(countToIndex( currentWriteIndex + 1) == 
			countToIndex(currentReadIndex)) 
		{ 
			return false; // La cola está llena	 
		} 
		// El propósito es obtener una ubicación de escritura 
	} while(!CAS(&m_writeIndex, currentWriteIndex, (currentWriteIndex+1))); 
	// Después de obtener la posición de escritura, currentWriteIndex es una variable temporal para guardar la posición que escribimos 
	// Ahora sabemos que este índice está reservado para nosotros. Úselo para guardar los datos 
	m_thequeue[countToIndex(currentWriteIndex)] = a_data; / / Actualice los datos a la posición correspondiente 

	// 2. Actualice la posición legible, presione m_maximumReadIndex+1 
 	// actualice el índice de lectura máximo después de guardar los datos. No fallaría si solo hay un hilo 
	// insertando en la cola. Podría fallar si hay más de 1 subproceso productor porque esta 
	// operación debe realizarse en el mismo orden que el CAS anterior 
	while(!CAS(&m_maximumReadIndex, currentWriteIndex, (currentWriteIndex + 1))) 
	{ 
		 // este es un buen lugar para generar el hilo en caso de que haya más 
		// hilos de software que procesadores de hardware y tenga más 
		// de 1 hilo de productor 
		// eche un vistazo a sched_yield (POSIX.1b) 
		sched_yield(); // Cuando el número de Los subprocesos exceden el número de núcleos de CPU; si no se abandona la CPU, siempre se repetirá aquí. 
	} 

	AtomicAdd(&m_count, 1); 

	devuelve verdadero; 

}

Análisis:
(1) Para la siguiente figura, se almacenan dos elementos en la cola. La posición indicada por WriteIndex es donde se insertará el nuevo elemento. El elemento en la posición señalada por ReadIndex aparecerá en la siguiente operación emergente.

(2) Cuando el productor está listo para insertar datos en la cola, primero solicita espacio aumentando el valor de WriteIndex. MaximumReadIndex apunta a la última ubicación donde se almacenan datos válidos (es decir, el final real de la cola).

(3) Una vez completada la solicitud de espacio, el productor puede copiar los datos a la ubicación que acaba de solicitar. Una vez completado, aumente MaximumReadIndex para que sea coherente con WriteIndex.

(4) Ahora hay 3 elementos en la cola y luego otro productor intenta insertar elementos en la cola.

(5) Antes de que el primer productor complete la copia de los datos, otro productor solicita un nuevo espacio y se prepara para copiar los datos. Ahora hay dos productores insertando datos en la cola al mismo tiempo.

(6) Ahora el productor comienza a copiar los datos. Después de completar la copia, la operación de incremento de MaximumReadIndex debe seguir estrictamente una secuencia: ** El primer hilo del productor primero incrementa MaximumReadIndex, y luego es el turno del segundo productor. ** La razón por la que se debe seguir estrictamente este orden es que debemos ** asegurarnos de que los datos se copien completamente en la cola antes de permitir que el hilo del consumidor los retire de la cola **.

El primer productor completa la copia de datos e incrementa MaximumReadIndex.

(7) Ahora el segundo productor puede incrementar MaximumReadIndex; el segundo productor ha completado el incremento de MaximumReadIndex y ahora hay 5 elementos en la cola.

4.2, sacar de la cola

plantilla <tiponombre ELEM_T, QUEUE_INT Q_SIZE> 
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::dequeue(ELEM_T &a_data) 
{ 
	QUEUE_INT currentMaximumReadIndex; 
	QUEUE_INT currentReadIndex; 

	do 
	{ 
		 // para garantizar la seguridad de los subprocesos cuando hay más de un subproceso productor 
       	// se define un segundo índice (m_maximumReadIndex) 
		currentReadIndex = m_readIndex; 
		currentMaximumReadIndex = m_maximumReadIndex; 

		if(countToIndex(currentReadIndex) == 
			countToIndex(currentMaximumReadIndex)) // 如果不为空,获取到读索引的位置
		{ 
			// la cola está vacía o 
			// un subproceso productor ha asignado espacio en la cola pero 
			// está esperando para confirmar los datos en él 
			se devuelve falso; 
		} 
		// recupera los datos de la cola 
		a_data = m_thequeue[countToIndex(currentReadIndex)]; // 从临时位置读取的

		// intente realizar ahora la operación CAS en el índice de lectura. Si tenemos éxito 
		// a_data ya contiene lo que m_readIndex señaló antes de 
		// aumentarlo 
		if(CAS(&m_readIndex, currentReadIndex, (currentReadIndex + 1))) 
		{ 
			AtomicSub(&m_count, 1); // 真正读取到了数据,元素-1 
			devuelve verdadero; 
		} 
	} mientras (verdadero); 

	afirmar(0); 
	 // Agregue esta declaración de retorno para evitar advertencias del compilador 
	return false; 

}

Análisis:
(1) La siguiente ilustración muestra cómo cambian los distintos subíndices cuando los elementos se retiran de la cola. Inicialmente hay 2 elementos en la cola. La posición indicada por WriteIndex es donde se insertará el nuevo elemento. El elemento en la posición señalada por ReadIndex aparecerá en la siguiente operación emergente.

(2) El hilo del consumidor copia el elemento en la posición ReadIndex de la matriz y luego intenta usar la operación CAS para aumentar ReadIndex en 1. Si la operación tiene éxito, el consumidor retira con éxito los datos de la cola. Debido a que las operaciones CAS son atómicas, solo un subproceso único puede actualizar el valor de ReadIndex al mismo tiempo. Si la operación falla, lea el nuevo valor ReadIndex para repetir la operación anterior (copiar datos, CAS).

(3) Ahora otro consumidor retira elementos de la cola y la cola queda vacía.

(4) Ahora hay un productor agregando elementos a la cola. Ha solicitado espacio con éxito, pero aún no ha completado la copia de datos. Cualquier otro consumidor que intente eliminar un elemento de la cola encontrará que la cola no está vacía (porque writeIndex no es igual a readIndex). Pero no puede leer los datos en la ubicación señalada por readIndex porque readIndex es igual a MaximumReadIndex. El consumidor continuará intentándolo una y otra vez en el ciclo do hasta que el productor complete la copia de datos y aumente el valor de MaximumReadIndex, o la cola quede vacía (esto sucederá en un escenario de múltiples consumidores ) .

(5) Cuando el productor completa la copia de datos, el tamaño de la cola es 1 y el hilo del consumidor puede leer los datos.

4.3 La necesidad de “renunciar” a la CPU cuando hay más de un hilo productor

La función en cola usa sched_yiedld() para ceder activamente la CPU. Para un algoritmo sin bloqueo, esta llamada parece un poco extraña.

Uno de los factores que afecta el rendimiento en un entorno de subprocesos múltiples es la corrupción de la caché. Una situación que causa daño a la caché es que un subproceso es apropiado. El sistema operativo necesita guardar el contexto del subproceso apropiado y luego cargar el contexto seleccionado como el siguiente subproceso programado. En este momento, los datos almacenados en caché en el caché dejarán de ser válidos porque son datos del subproceso reemplazado en lugar de datos del nuevo subproceso.

Entonces, cuando este algoritmo llama a sched_yield() significa decirle al sistema operativo: "Quiero ceder tiempo de procesador a otros subprocesos porque quiero esperar a que suceda algo". Una diferencia importante entre los algoritmos sin bloqueo y los algoritmos sincronizados mediante mecanismos de bloqueo es que los algoritmos sin bloqueo no se bloquean durante la sincronización de subprocesos.

Entonces, ¿ por qué solicitamos activamente al sistema operativo que nos anticipe aquí?  Está relacionado con cuántos subprocesos productores almacenan datos simultáneamente en la cola: la operación CAS realizada por cada subproceso productor debe seguir estrictamente el orden FIFO. para espacio, y el otro se utiliza para notificar a los consumidores que los datos se han escrito y se pueden leer.

Si la aplicación tiene solo un productor operando en esta cola, sche_yield() nunca tendrá la oportunidad de ser llamado y la segunda operación CAS nunca fallará. Porque en el caso de un productor nadie puede destruir el orden FIFO en el que el productor realiza estas dos operaciones CAS.

Los problemas surgen cuando más de un subproceso productor almacena datos en la cola. En resumen, un productor pasa la primera

La operación CAS solicita espacio, luego escribe los datos en el espacio solicitado y luego ejecuta la segunda operación CAS para notificar al consumidor que los datos están listos para ser leídos. Esta segunda operación CAS debe seguir el orden FIFO, es decir, si el hilo A es el primero en ejecutar la primera operación CAS, entonces también debe ser el primero en ejecutar la segunda operación CAS. Si el hilo A es el primero en ejecutar la segunda operación CAS, se detiene después de una operación CAS, y luego el subproceso B completa la primera operación CAS, entonces el subproceso B no podrá completar la segunda operación CAS, porque tiene que esperar a que A complete la segunda operación CAS primero. Y aquí es donde surge el problema.

5. Código fuente completo

#ifndef _ARRAYLOCKFREEQUEUEIMP_H___ 
#define _ARRAYLOCKFREEQUEUEIMP_H___ 
#include "ArrayLockFreeQueue.h" 

#include <assert.h> 
#incluye plantilla "atom_opt.h" 

<typename ELEM_T, QUEUE_INT Q_SIZE> 
ArrayLockFreeQueue<ELEM_T, Q_SIZE>::ArrayLockFreeQueue() : 
	m_writeIndex (0 ), 
	m_readIndex(0), 
	m_maximumReadIndex(0) 
{ 
	m_count = 0; 
} 

plantilla <nombre de tipo ELEM_T, QUEUE_INT Q_SIZE> 
ArrayLockFreeQueue<ELEM_T, Q_SIZE>::~ArrayLockFreeQueue() 
{ 

} 

plantilla <nombre de tipo ELEM_T, QUEUE_INT Q_SIZE> 
en línea QUEUE_INT ArrayLockFreeQueue<ELEM_T, Q_SIZE>::countToIndex(QUEUE_INT a_count) 
{ 
	return (a_count) %Q_SIZE); // 
plantilla de plantilla <nombre de tipo ELEM_T, QUEUE_INT Q_SIZE> 
QUEUE_INT ArrayLockFreeQueue<ELEM_T, Q_SIZE>::size() 
{ 
QUEUE_INT 
	currentWriteIndex = m_writeIndex; 
	QUEUE_INT currentReadIndex = m_readIndex; 
	if(currentWriteIndex>=currentReadIndex) 
		devuelve currentWriteIndex - currentReadIndex; 
	de lo contrario 
		, devuelve Q_SIZE + currentWriteIndex - currentReadIndex; 
} 
plantilla <tiponombre ELEM_T, QUEUE_INT Q_SIZE> 
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::enqueue(const ELEM_T &a_data) 
{ 
	QUEUE_INT currentWriteIndex; // 获取写指针的位置
	QUEUE_INT currentReadIndex; 
	// 1. 获取可写入的位置
	do 
	{ 
		currentWriteIndex = m_writeIndex; 
		currentReadIndex = m_readIndex; 
		if(countToIndex(currentWriteIndex + 1) == 
			countToIndex(currentReadIndex)) 
		{ 
			return false; // 队列已经满了	
		} 
		// 目的是为了获取一个能写入的位置
	} while(!CAS(&m_writeIndex, currentWriteIndex, (currentWriteIndex+1))); 
	// 获取写入位置后 currentWriteIndex 是一个临时变量,保存我们写入的位置
	// Ahora sabemos que este índice está reservado para nosotros. Úselo para guardar los datos 
	m_thequeue[countToIndex(currentWriteIndex)] = a_data; // 把数据更新到对应的位置
	// 2. 更新可读的位置,按着m_maximumReadIndex+1的操作
 	// actualiza el índice de lectura máximo después de guardar los datos. No fallaría si solo hay un hilo 
	// insertándose en la cola. Podría fallar si hay más de 1 subproceso productor porque esta 
	// operación debe realizarse en el mismo orden que el CAS anterior 
	while(!CAS(&m_maximumReadIndex, currentWriteIndex,




 
		sched_yield(); // Cuando el número de subprocesos excede el número de núcleos de la CPU, si no se abandona la CPU, el ciclo continuará aquí. 
	} 

	AtomicAdd(&m_count, 1); 

	retorno verdadero; 

} 

plantilla <nombre de tipo ELEM_T, QUEUE_INT Q_SIZE> 
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::try_dequeue(ELEM_T &a_data) 
{ 
    retorno dequeue(a_data); 
} 

plantilla <nombre de tipo ELEM_T, QUEUE_INT Q_SIZE> 
bool ArrayLockFreeQueue<ELEM_T, Q_SIZE>::dequeue(ELEM_T &a_data) 
{ 
	QUEUE_INT currentMaximumReadIndex; 
	QUEUE_INT currentReadIndex; 

	do 
	{ 
		 // para garantizar la seguridad de los subprocesos cuando hay más de 1 subproceso productor 
       	// se define un segundo índice (m_maximumReadIndex) 
		currentReadIndex = m_readIndex; 
		currentMaximumReadIndex = m_maximumReadIndex; 

		if(countToIndex(currentReadIndex) == 
			countToIndex(currentMaximumReadIndex)) // Si no está vacío, obtiene la posición del índice de lectura 
		{ 
			// la cola está vacía o 
			// un subproceso productor ha asignado espacio en la cola pero 
			// está esperando enviar los datos en ella 
			return false; 
		} 
		// recupera los datos de la cola 
		a_data = m_thequeue[countToIndex(currentReadIndex)]; // lee desde una ubicación temporal 

		// intenta realizar ahora el CAS operación en el índice de lectura. Si tenemos éxito 
		// a_data ya contiene lo que m_readIndex señaló antes de 
		// aumentarlo 
		if(CAS(&m_readIndex, currentReadIndex, (currentReadIndex + 1))) 
		{ 
			AtomicSub(&m_count, 1); // verdadero Los datos se leen, el elemento -1 
			devuelve verdadero; 
		} 
	} while(true); 

	afirmar(0); 
	 // Agregue esta declaración de retorno para evitar que las advertencias del compilador 
	devuelvan falso; 

} 

#endif

Prueba de código fuente

#include "ArrayLockFreeQueue.h" 
ArrayLockFreeQueue<int> arraylockfreequeue; 
void *arraylockfreequeue_producer_thread(void *argv) 
{ 
  PRINT_THREAD_INTO(); 
  recuento int = 0; 
  int write_failed_count = 0; 

  for (int i = 0; i < s_queue_item_num;) 
  { 
    if (arraylockfreequeue.enqueue(count)) // enqueue的顺序是无法保证的,我们只能计算enqueue的个数
    { 
      count = lxx_atomic_add(&s_count_push, 1); 
      yo ++; 
    } 
    más 
    { 
      write_failed_count++; 
      // printf("%s %lu falló en la puesta en cola, q:%d\n", __FUNCTION__, pthread_self(), arraylockfreequeue.size()); 
      sched_yield(); 
      // usleep(10000); 
    } 
  } 
  // printf("%s %lu write_failed_count:%d\n", __FUNCTION__, pthread_self(), write_failed_count) 
  PRINT_THREAD_LEAVE(); 
  devolver NULO; 
} 

void *arraylockfreequeue_consumer_thread(void *argv) 
{ 
  int último_valor = 0; 
  PRINT_THREAD_INTO(); 
  valor entero = 0; 
  int read_failed_count = 0; 
  while (true) 
  { 

    if (arraylockFreequeue.dequeue (valor)) 
    { 
      if (s_consumer_thread_num == 1 && s_producer_thread_num == 1 && (last_value + 1)! = valor) // 只 有 一 入 一 出 情况 才 才 有 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比 对比
      { 
        // printf("pid:%lu, -> valor:%d, esperado:%d\n", pthread_self(), valor, último_valor); 
      } 
      lxx_atomic_add(&s_count_pop, 1); 
      último_valor = valor; 
    } 
    más 
    { 
      read_failed_count++; 
      // printf("%s %lu sin datos, s_count_pop:%d, valor:%d\n", __FUNCTION__, pthread_self(), s_count_pop, valor); 
      // usleep(100); 
      sched_yield(); 
    } 

    if (s_count_pop >= s_queue_item_num * s_producer_thread_num) 
    { 
      // printf("%s dequeue:%d, s_count_pop:%d, %d, %d\n", __FUNCTION__, last_value, s_count_pop, s_queue_item_num, s_consumer_thread_num); 
      romper; 
    } 
  } 
  // printf("%s %lu read_failed_count:%d\n", __FUNCTION__, pthread_self(), read_failed_count) 
  PRINT_THREAD_LEAVE(); 
  devolver NULO; 
}

Resumir

  • La cola sin bloqueo ArrayLockFreeQueue basada en matrices circulares es relativamente sencilla de implementar.
  • La cola de mensajes sin bloqueo es adecuada para escenarios en los que el rendimiento de datos es superior a 100.000 por segundo y las operaciones de datos toman menos tiempo.
  • currentMaximumReadIndex significa que sus datos anteriores se pueden leer, pero su ubicación actual no se puede leer.

Haga clic para seguir y conocer las nuevas tecnologías de Huawei Cloud lo antes posible ~

El autor del marco de código abierto NanUI pasó a vender acero y el proyecto fue suspendido. La primera lista gratuita en la App Store de Apple es el software pornográfico TypeScript. Acaba de hacerse popular, ¿por qué los grandes empiezan a abandonarlo? Lista de octubre de TIOBE: Java tiene la mayor caída, C# se acerca Java Rust 1.73.0 lanzado Un hombre fue alentado por su novia AI a asesinar a la Reina de Inglaterra y fue sentenciado a nueve años de prisión Qt 6.6 publicado oficialmente Reuters: RISC-V La tecnología se convierte en la clave de la guerra tecnológica entre China y Estados Unidos. Nuevo campo de batalla RISC-V: no controlado por ninguna empresa o país, Lenovo planea lanzar una PC con Android.
{{o.nombre}}
{{m.nombre}}

Supongo que te gusta

Origin my.oschina.net/u/4526289/blog/10117193
Recomendado
Clasificación