Descripción general del programador goroutine (11)

El siguiente contenido se reproduce de  https://mp.weixin.qq.com/s/2wkZyOFAyhHgvNkEkXamkQ

Awa me encanta escribir programas originales Zhang  source Travels  2019-05-01

Este artículo es el undécimo capítulo de la serie "Análisis de escenarios de código fuente de Go Scheduler", y también es la primera subsección del segundo capítulo.

Introducción a la goroutine

Goroutine es un subproceso en modo usuario implementado en lenguaje Go. Se utiliza principalmente para resolver el problema de los subprocesos del sistema operativo que son demasiado "pesados". El llamado demasiado pesado se manifiesta principalmente en los dos aspectos siguientes:

  1. La creación y el cambio son demasiado pesados : la creación y el cambio de subprocesos del sistema operativo deben ingresar al kernel, y el costo de rendimiento de ingresar al kernel es relativamente alto y la sobrecarga es grande;

  2. El uso de memoria es demasiado pesado : por un lado, para evitar el desbordamiento de las pilas de subprocesos del sistema operativo en casos extremos, el kernel asignará una memoria de pila más grande (espacio de direcciones virtuales) de forma predeterminada al crear subprocesos del sistema operativo. mucha memoria física), pero en la mayoría de los casos, los subprocesos del sistema no pueden usar tanta memoria, lo que conduce al desperdicio; por otro lado, una vez que se crea e inicializa el espacio de memoria de la pila, su tamaño ya no se puede usar. cambios, lo que determina que la pila de subprocesos del sistema todavía tiene el riesgo de desbordarse en algunos escenarios especiales.

Por el contrario, las gorutinas en modo de usuario son mucho más ligeras:

  1. Goroutine es un subproceso en modo de usuario, su creación y cambio se completan en el código de usuario sin ingresar al kernel del sistema operativo, por lo que su sobrecarga es mucho menor que la creación y el cambio de subprocesos del sistema;

  2. Cuando se inicia goroutine, el tamaño de pila predeterminado es de solo 2k, que es suficiente en la mayoría de los casos. Incluso si no es suficiente, la pila de goroutine se expandirá automáticamente. Al mismo tiempo, si la pila es demasiado grande y derrochadora, puede automáticamente encoger, por lo que no hay pila El riesgo de desbordamiento no causará mucho desperdicio de espacio de memoria de la pila.

Es precisamente debido a la implementación de un hilo tan ligero en el lenguaje Go que podemos crear fácilmente miles o incluso millones de gorutinas en el programa Go para ejecutar tareas simultáneamente sin preocuparnos demasiado por el rendimiento y problemas de memoria, etc.

Nota: Para evitar confusiones, a partir de ahora, todos los términos hilos que aparecen después se refieren a hilos del sistema operativo, y ya no llamamos goroutine a qué hilo, sino que usamos directamente la palabra goroutine.

Programador y modelo de subprocesos

Al analizar la programación de subprocesos del sistema operativo en el Capítulo 1, mencionamos que goroutine se basa en subprocesos del sistema operativo e implementa un modelo de subprocesos de dos niveles de muchos a muchos (M: N) entre él y los subprocesos del sistema operativo.

M: N aquí significa que M goroutines se ejecutan en N subprocesos del sistema operativo, el kernel es responsable de programar estos N subprocesos del sistema operativo, y estos N subprocesos del sistema son responsables de programar y ejecutar estas M goroutines.

La llamada programación de goroutines se refiere al proceso en el que el código del programa selecciona la goroutine apropiada en un momento apropiado de acuerdo con un cierto algoritmo y la pone en la CPU para que se ejecute. El código del programa responsable de programar la goroutine se llama goroutine planificador . El uso de un pseudocódigo extremadamente simplificado para describir el flujo de trabajo del programador goroutine es aproximadamente el siguiente:

// Código de inicialización cuando el programa se inicia 
... 
para i: = 0; i <N; i ++ {// Crear N subprocesos del sistema operativo para ejecutar la función de programación 
    create_os_thread (programación) // Crear un subproceso del sistema operativo para ejecutar la función de programación 
} 

// la función de programación implementa la lógica de programación 
func schedule () { 
   for {// bucle de programación 
         // Encuentra una goroutine que se ejecutará desde M goroutines de acuerdo con un algoritmo determinado 
         g: = find_a_runnable_goroutine_from_M_goroutines () 
         run_g (g) // La CPU se ejecuta la goroutine y no devuelve 
         save_status_of_g (g) hasta que se necesiten programar otras goroutines . // Guarde el estado de la goroutine, principalmente el valor del registro 
    } 
}

El significado de este pseudocódigo es que después de que se ejecuta el programa, se crean N subprocesos del sistema operativo programados por el kernel (para la conveniencia de la descripción, llamamos a estos subprocesos del sistema como subprocesos de trabajo ) para ejecutar la función de programación, y la función de programación es en un ciclo de programación Seleccione repetidamente una goroutine que deba ejecutarse desde M goroutines y salte a la goroutine para ejecutar, y luego vuelva a la función de programación hasta que otras goroutines deban programarse. Guarde el estado de la goroutine que acaba de ejecutarse a través de save_status_of_g, y luego búsquelo nuevamente.

Se debe enfatizar que este pseudocódigo ha hecho un alto grado de abstracción, modificación y simplificación del código de programación de goroutine. Se coloca aquí solo para ayudarnos a entender el modelo de programación de dos niveles de goroutine a un nivel macro. Los principios y detalles de implementación se comenzarán con este capítulo para una introducción completa.

Descripción general de la estructura de datos del planificador

En el primer capítulo, cuando discutimos los subprocesos del sistema operativo y su programación, también dijimos que simplemente podemos resumir la programación del kernel de los subprocesos del sistema de la siguiente manera: Cuando se ejecuta el código del sistema operativo, el programador del kernel selecciona un subproceso de acuerdo con un cierto algoritmo y luego El valor del registro guardado por el hilo en la memoria se coloca en el registro correspondiente a la CPU para reanudar la ejecución del hilo.

El principio de programar goroutines por subprocesos del sistema es el mismo que el principio de programar subprocesos del sistema por kernels. La esencia es guardar y modificar el valor de los registros de la CPU para lograr el propósito de cambiar subprocesos / goroutines.

Por lo tanto, para realizar la programación de goroutine, es necesario introducir una estructura de datos para guardar el valor del registro de la CPU y alguna otra información de estado de la goroutine. En el código fuente del programador de lenguaje Go, esta estructura de datos es una estructura llamada g , que se guarda toda la información de goroutine. Cada objeto de instancia de la estructura representa una goroutine. El código del planificador puede programar la goroutine a través del objeto g. Cuando la goroutine se transfiere desde la CPU, el código del planificador es responsable de configurar el registro de la CPU El valor se almacena en la variable miembro del objeto g. Cuando se programa la ejecución de goroutine, el código del planificador es responsable de restaurar el valor del registro guardado por la variable miembro del objeto g en el registro de la CPU.

Para realizar la programación de goroutines, no es suficiente tener solo el objeto de estructura g. Se necesita al menos un contenedor para almacenar todas las goroutines (ejecutables), de modo que los subprocesos de trabajo puedan encontrar las goroutines que deben programarse para ejecutarse, por lo que se introduce el planificador Go. La estructura schedt se utiliza para almacenar la información de estado del planificador mismo, por otro lado, también tiene una cola de ejecución para almacenar goroutines. Debido a que cada programa de Go tiene solo un programador, solo hay un objeto de instancia de la estructura schedt en cada programa de Go. El objeto de instancia se define como una variable global compartida en el código fuente, de modo que cada hilo de trabajo pueda acceder a él y a la goroutine cola de ejecución que posee, llamamos a esta cola de ejecución la cola de ejecución global .

Al hablar de la cola de ejecución global, los lectores pueden adivinar que debería haber una cola de ejecución local. Esto es cierto, porque la cola de ejecución global es legible y escribible por todos los subprocesos de trabajo, por lo que el acceso a ella requiere bloqueos.Sin embargo, en un sistema ocupado, los bloqueos pueden causar graves problemas de rendimiento. Por lo tanto, el programador introduce una cola de ejecución de goroutine local privada para cada subproceso de trabajo . El subproceso de trabajo prefiere usar su propia cola de ejecución local y solo acceder a la cola de ejecución global cuando sea necesario. Esto reduce en gran medida los conflictos de bloqueo y mejora la concurrencia de los subprocesos de trabajo. En el código fuente del planificador Go, la cola de ejecución local se incluye en el objeto de instancia de la estructura p , y cada subproceso de trabajo que ejecuta el código go está asociado con un objeto de instancia de la estructura p.

Además de las estructuras g, schedt yp descritas anteriormente, también hay una estructura m en el código fuente del planificador de Go que representa un subproceso de trabajo . Cada subproceso de trabajo tiene un objeto de instancia único de la estructura m correspondiente. Además para registrar la información de estado del hilo de trabajo, como las posiciones inicial y final de la pila, la goroutine que se está ejecutando actualmente y si está inactiva, el objeto de estructura m también mantiene la relación de enlace con el objeto de instancia de la estructura p a través de el puntero. Por lo tanto, a través de m, se puede encontrar la goroutine que está ejecutando el subproceso de trabajo correspondiente, y se pueden encontrar recursos como la cola de ejecución parcial del subproceso de trabajo. El siguiente es un diagrama de la relación entre g, p, my schedt:

 

imagen

 

En la figura anterior, el patrón circular representa el objeto de instancia de la estructura g, el triángulo representa el objeto de instancia de la estructura m y el cuadrado representa el objeto de instancia de la estructura p. La g roja representa la rutina gor que el hilo de trabajo correspondiente am se está ejecutando, y la g gris representa una goroutine que está en la cola de ejecución y está esperando ser programada para ejecutarse.

Como se puede ver en la figura anterior, cada m está vinculado a una p, y cada p tiene una cola de goroutine local privada El hilo correspondiente a m obtiene la goroutine de las colas de goroutine local y global y la ejecuta.

Anteriormente dijimos que cada subproceso de trabajo tiene un objeto de estructura m correspondiente a él, pero no explicamos cómo se corresponden entre sí.Cómo el código ejecutado por el subproceso de trabajo encuentra su propio objeto de instancia de estructura m ¿Qué?

Si solo hay un hilo de trabajo, entonces solo habrá un objeto de estructura M. El problema es simple, simplemente defina una variable de estructura m global. Pero tenemos varios subprocesos de trabajo y varios m necesitamos correspondencia uno a uno, ¿qué debemos hacer? ¿Recuerda el almacenamiento local de subprocesos que discutimos en el Capítulo 1? En ese momento dijimos que el almacenamiento local de subprocesos es en realidad una variable global privada para el subproceso. ¿No es eso lo que necesitamos? ! Siempre que cada subproceso de trabajo tenga su propia variable global de estructura m privada, podemos usar el mismo nombre de variable global en diferentes subprocesos de trabajo para acceder a diferentes objetos de estructura m, lo que resuelve perfectamente nuestro problema.

Específicamente para el código del programador goroutine, cada subproceso de trabajo utiliza el mecanismo de almacenamiento local del subproceso para implementar una variable global privada que apunta al objeto de instancia de estructura m para el subproceso de trabajo justo antes de que se cree y entre en el ciclo de programación, por lo que en el código que sigue Utilice esta variable global para acceder a su propio objeto de estructura my los objetos p y g asociados con m.

Con la estructura de datos anterior y el mecanismo de mapeo entre el hilo de trabajo y la estructura de datos, podemos escribir el pseudocódigo de programación anterior de manera más completa:

// Código de inicialización cuando el programa se inicia 
... 
para i: = 0; i <N; i ++ {// Crear N subprocesos del sistema operativo para ejecutar la función de programación 
     create_os_thread (programación) // Crear un subproceso del sistema operativo para ejecutar la función de programación 
} 


// Defina una variable global privada de hilo, tenga en cuenta que es un puntero al objeto de estructura m 
// ThreadLocal se usa para definir la variable global privada de hilo 
ThreadLocal self * m  
// función de programación realiza la lógica de programación 
func schedule () { 
    / / Cree e inicialice el objeto de estructura m, y 
    asígnelo a la variable global privada self self = initm ()    
    para {// bucle de despacho 
          si (self.p.runqueue está vacío) { 
                 // averigüe en la cola de ejecución global según a un determinado algoritmo Una goroutine que debe ejecutarse 
                 g: = find_a_runnable_goroutine_from_global_runqueue () 
           } else { 
           } 
                 // Encuentra una goroutine que debe ejecutarse desde una cola de ejecución local privada de acuerdo con un determinado algoritmo
                 g: = find_a_runnable_goroutine_from_local_runqueue () 
          run_g (g) // La CPU ejecuta la goroutine y no devuelve 
          save_status_of_g (g) hasta que se necesiten programar otras goroutines . // Guarde el estado de la goroutine, principalmente el valor del registro 
     } 
}

Desde el pseudocódigo anterior, no necesitamos variables globales privadas de subprocesos en absoluto, solo defina una variable local en la función de programación. Pero el código de programación real es intrincado, no solo esta función de programación necesita acceder a m, sino que también muchos otros lugares necesitan acceder a él, por lo que las variables globales deben usarse para facilitar que otros lugares accedan a myg y p relacionados con m.

Después de una breve introducción al programador del lenguaje Go y la estructura de datos que necesita, echemos un vistazo a la definición de las estructuras mencionadas anteriormente en el código de programación Go.

Estructura importante

Hay muchos campos en estas estructuras que se describen a continuación, y los detalles involucrados también son muy complicados. Con solo mirar las definiciones de estas estructuras, no necesitamos y realmente no podemos entender su propósito, por lo que aquí solo necesitamos Obtenga una idea aproximada. No importa si no comprende o no recuerda. Con el análisis gradual en profundidad del código más adelante, definitivamente tendremos una comprensión más clara y clara de estas estructuras. Para ahorrar espacio, las siguientes definiciones de estructura omiten miembros que no tienen nada que ver con el planificador. Además, todas las definiciones de estas estructuras se encuentran en el archivo runtime / runtime2.go bajo la ruta del código fuente del lenguaje Go.

estructura de pila

La estructura de la pila se utiliza principalmente para registrar la información de la pila utilizada por la goroutine, incluidas las posiciones superior e inferior de la pila:

// La pila describe una pila de ejecución de Go. 
// Los límites de la pila son exactamente [lo, hi), 
// sin estructuras de datos implícitas en ninguno de los lados. 
// Se utiliza para registrar las posiciones inicial y final de la pila utilizada por goroutine 
type stack struct {   
    lo uintptr // parte superior de la pila, apuntando a la dirección de memoria baja 
    hi uintptr // inferior de la pila, apuntando a la dirección de memoria alta 
}

estructura gobuf

La estructura gobuf se utiliza para guardar la información de programación de la goroutine, incluyendo principalmente los valores de varios registros de la CPU:

type gobuf struct { 
    // Las compensaciones de sp, pc yg son conocidas por (codificadas en) libmach. 
    // 
    // ctxt es inusual con respecto a GC: puede ser una 
    // función asignada al montón, por lo que GC necesita rastrearla, pero 
    // debe configurarse y borrarse del ensamblaje, donde es 
    // difícil de tener escribir barreras. Sin embargo, ctxt es realmente un 
    // registro en vivo guardado, y solo lo intercambiamos entre 
    // el registro real y el gobuf. Por lo tanto, lo tratamos como una 
    // raíz durante el escaneo de pila, lo que significa que el ensamblaje que guarda 
    // y lo restaura no necesita barreras de escritura. Sigue siendo  
    // se escribe como un puntero para que cualquier otra escritura de Go get
    // escribir barreras. 
    sp uintptr // 保存 CPU 的 rsp 寄存器 的 值
    pc uintptr // guardar el valor del registro rip de la CPU 
    g guintptr // registrar qué rutina de gor del objeto gobuf actual pertenece a 
    ctxt unsafe.Pointer 
 
    // guardar el valor de retorno de la llamada al sistema, porque si p es reemplazado por otros subprocesos de trabajo después regresando de la llamada al sistema, 
    // Luego, esta goroutine se colocará en la cola de ejecución global para ser programada por otros subprocesos de trabajo, y otros subprocesos necesitan conocer el valor de retorno de la llamada al sistema. 
    ret sys.Uintreg   
    lr uintptr 
 
    // Guarde el valor del registro rip de la CPU 
    bp uintptr // para GOEXPERIMENT = framepointer 
}

g estructura

La estructura g se utiliza para representar una goroutine. La estructura guarda toda la información de la goroutine, incluida la pila, la estructura gobuf y alguna otra información de estado:

// 前 文 所说 的 g 结构 体 , 它 代表 了 一个 goroutine 
type g struct { 
    // Stack parameters. 
    // pila describe la memoria de pila real: [stack.lo, stack.hi). 
    // stackguard0 es el puntero de pila comparado en el prólogo de crecimiento de pila de Go. 
    // Es stack.lo + StackGuard normalmente, pero puede ser StackPreempt para activar una preferencia. 
    // stackguard1 es el puntero de pila comparado en el prólogo de crecimiento de pila de C. 
    // Es stack.lo + StackGuard en las pilas g0 y gsignal. 
    // Es ~ 0 en otras pilas de goroutine, para activar una llamada a morestackc (y bloquearse). 
 
    // 记录 该 goroutine 使用 的 栈
    stack stack // offset conocido por el tiempo de ejecución / cgo  
    // El Los siguientes dos miembros se utilizan para comprobar el desbordamiento de la pila para realizar la expansión automática de la pila y la programación de preferencia también utilizará stackguard0
    stackguard0 uintptr // offset conocido por liblink 
    liblink stackguard1 uintptr // desplazamiento conocido por liblink 

    ...... 
 
    // qué hilo de trabajo está ejecutando este goroutine 
    m * m // actual m; desplazamiento conocido para armar liblink 
    // guardar información de programación, Principalmente el valor de varios registros 
    sched gobuf 
 
    ...... 
    // El campo schedlink apunta a la siguiente g 
    en la cola de ejecución global , // todos los 
    gs en la cola de ejecución global forman una lista enlazada schedlink guintptr 

    ..... . 
    // Indicador de programación de preferencia, si necesita programación de preferencia, establezca preempt en true 
    preempt bool // señal de preferencia, duplica stackguard0 = stackpreempt 

   ...... 
}

m estructura

La estructura m se utiliza para representar el hilo de trabajo. Guarda la información de la pila utilizada por m mismo, la goroutine que se está ejecutando actualmente y la p vinculada a m. Consulte los comentarios en la siguiente definición para obtener más detalles:

type m struct { 
    // g0 se utiliza principalmente para registrar la información de la pila utilizada por el hilo de trabajo. Esta pila es necesaria cuando se ejecuta el código de programación. 
    // Cuando se ejecuta el código del usuario goroutine, se utiliza la propia pila del usuario goroutine, y el cambio de pila ocurre durante la programación 
    g0 * g // goroutine con la pila de programación 

    // realiza el enlace entre el objeto de estructura m y el hilo de trabajo a través de TLS 
    tls [6] uintptr // almacenamiento local de hilo (para registro externo x86) 
    mstartfn func () 
    // apuntar al trabajo El objeto de estructura g de la goroutine que está ejecutando el hilo 
    curg * g // goroutine en ejecución actual 
 
    // Registrar el objeto de estructura p vinculado al hilo de trabajo actual 
    p puintptr // adjunto p para ejecutar el código go (nil si no está ejecutando el código go) 
    nextp puintptr 
    oldp puintptr // la p que se adjuntó antes de ejecutar una llamada al sistema 
   
    // estado giratorio: indica que el hilo de trabajo actual está intentando robar goroutine de la cola de ejecución local de otros hilos de trabajo
    spinning bool // m está sin trabajo y está buscando activamente trabajo 
    bloqueado bool // m está bloqueado en una nota 
   
    // Cuando no es necesario ejecutar una goroutine, el hilo de trabajo duerme en este miembro del parque, 
    // otros hilos se despiertan a través this park The worker thread 
    park note 
    // Registre una lista vinculada de todos los subprocesos de trabajo 
    alllink * m // en allm 
    schedlink muintptr 

    // El valor del subproceso de la plataforma Linux es el ID del 
    subproceso del sistema operativo thread uintptr // thread handle 
    freelink * m // en sched.freem 

    ...... 
}

p estructura

La estructura p se utiliza para guardar los recursos necesarios para que el hilo de trabajo ejecute el código go, como la cola de ejecución de la goroutine, la caché utilizada para la asignación de memoria, etc.

type p struct { 
    lock mutex 

    status uint32 // uno de pidle / prunning / ... 
    link puintptr 
    schedtick uint32 // incrementado en cada llamada al programador 
    syscalltick uint32 // incrementado en cada llamada al sistema 
    sysmontick sysmontick // último tic observado por sysmon 
    m muintptr // Vínculo de retroceso al m asociado (nulo si está inactivo) 

    ...... 

    // Cola de goroutines ejecutables. Accedido sin cerradura. 
    // 本地 goroutine 运行 队列
    runqhead uint32 // 队列 头
    runqtail uint32 // 队列 尾
    runq [256] guintptr // 使用 数组 实现 的 循环 队列
    // runnext, si no es nulo, es un G ejecutable que fue preparado por
    // la G actual y debería ejecutarse a continuación en lugar de lo que hay 
    // runq si queda tiempo en el 
    segmento // de tiempo de la G en ejecución . Heredará el tiempo restante en el 
    // segmento de tiempo actual . Si un conjunto de goroutines está bloqueado en un 
    // patrón de comunicación y espera, esto programa que se establece como una 
    // unidad y elimina la (potencialmente grande) programación 
    // latencia que de otro modo surge al agregar las 
    // goroutines listas hasta el final de la cola de ejecución. 
    runnext guintptr 

    // G disponibles (estado == Gdead) 
    gFree struct { 
        gList 
        n int32 
    } 

    ...... 
}

estructura schedt

La estructura schedt se utiliza para guardar la información de estado del planificador y la cola de ejecución global de la goroutine:

escriba schedt struct { 
    // acceso atómico. manténgase en la parte superior para asegurar la alineación en sistemas de 32 bits. 
    goidgen uint64 
    lastpoll uint64 

    lock mutex 

    // Cuando aumente nmidle, nmidlelocked, nmsys o nmfreed, 
    // asegúrese de llamar a checkdead (). 

    // Una lista vinculada compuesta de subprocesos de trabajo inactivos 
    midle muintptr // 
    número de 
    subprocesos de trabajo inactivos nmidle int32 // número de subprocesos de trabajo inactivos nmidle int32 // número de m inactivos en espera de trabajo 
    nmidlelocked int32 // número de m bloqueados esperando trabajo 
    mnext int64 // número de m que se han creado y el siguiente ID de M 
    // Solo se pueden crear  
    hilos de trabajo maxmcount maxmcount int32 // número máximo de m permitido (o morir)
    maxmcount subprocesos de trabajo nmsys int32 // número de m del sistema que no se cuentan para interbloqueo
    nmfreed int64 // número acumulativo de ngsys uint32 de m liberados 

    // número de goroutines del sistema; actualizado atómicamente 

    // lista enlazada de objetos de estructura p libres 
    pidle puintptr // p's inactivos 
    // número de objetos de estructura p libres 
    npidle uint32 
    nmspinning uint32 // Ver "Worker thread parking / unparking" comentario en proc.go. 

    // Cola global ejecutable. 
    // goroutine global run queue 
    runq gQueue 
    runqsize int32 

    ...... 

    // Caché global de G muertos. 
    // gFree es una lista enlazada de g objetos de estructura correspondientes a todas las goroutines salidas 
    // se utilizan para almacenar en caché g objetos de estructura para evitar la reasignación de memoria cada vez que se crea una goroutine 
        pila de gList // Gs con pilas 
    gFree struct {
        lock mutex
        noStack gList // Gs sin pilas 
        n int32 
    } 
 
    ...... 
}

Variables globales importantes

allgs [] * g // guardar todos los g 
allm * m // todos los m forman una lista enlazada, incluyendo el siguiente m0 
allp [] * p // guardar todos los p, len (allp) == gomaxprocs 

ncpu int32 / / El número de núcleos de cpu en el sistema, inicializados por el código de tiempo de ejecución cuando el programa inicia 
gomaxprocs int32 // El valor máximo de p, que es igual a ncpu por defecto, pero 

sched schedt se puede modificar a través de GOMAXPROCS // El objeto de estructura del planificador, que registra el estado de trabajo del planificador 

m0 m // representa el hilo principal del proceso 
g0 g // m0's g0, es decir, m0.g0 = & g0

Cuando se inicializa el programa, todas estas variables se inicializarán con un valor de 0, los punteros se inicializarán con punteros nulos, los segmentos se inicializarán con segmentos nulos, int se inicializarán con el número 0 y todas las variables miembro de la estructura se inicializarán a sus tipos de acuerdo con sus propios tipos. El valor de 0. Entonces allgs, allm y allp no contienen g, myp cuando el programa se inicia por primera vez.


Finalmente, si crees que este artículo te es útil, por favor ayúdame a hacer clic en "Mirar" en la esquina inferior derecha del artículo o reenviarlo al círculo de amigos, ¡muchas gracias!

imagen

Supongo que te gusta

Origin blog.csdn.net/pyf09/article/details/115238709
Recomendado
Clasificación