Concurrencia y bloqueo en un servidor de chat -- urrency campo con go campo con locking camp codereview Relacionados El problema

Concurrency and locking in a chat server


3
vote

problema

Español

Nunca antes he hecho la programación concurrente, pero he escrito uno usando canales y RWMUTEX, pero no estoy seguro de si mi enfoque es idiomático. Siento que todo podría usar canales (tal vez).

El protocolo es simple: los mensajes están delimitados por ' N'. El primer mensaje de mensaje envía es su nombre. El resto son mensajes normales. Utilicé un mapa porque su agregar + eliminar es más rápido, y porque no me preocupa el pedido. Estoy preocupado principalmente por las cerraduras en los últimos 4 métodos. ¿Podría usar canales aquí en su lugar? ¿Estoy usando bloqueos correctamente?

  type message struct {     message string     client  *client }  type client struct {     conn   net.Conn     reader *bufio.Reader     name   string }  type Server struct {     l           net.Listener     clients     map[*client]struct{}     messageChan chan message     port        uint16     name        string     m           sync.RWMutex }  // NewServer starts listening, and returns an initialized server. If an error occured while listening started, (nil, error) is returned. func NewServer(name string, port uint16) (*Server, error) {     p := strconv.FormatUint(uint64(port), 10)     l, e := net.Listen("tcp", "localhost:"+p)     if e != nil {         return nil, e     }     return &Server{l, make(map[*client]struct{}), make(chan message), port, name, sync.RWMutex{}}, nil }  // Start starts accepting clients + managing messages. func (r *Server) Start() {     go r.sendMessages()     for {         conn, e := r.l.Accept()         if e != nil {             continue         }         go r.handleConn(conn)     } }  func (r *Server) handleConn(conn net.Conn) {     reader := bufio.NewReader(conn)     name, e := reader.ReadString(' ')     name = strings.TrimSpace(name)     if e != nil {         conn.Close()         return     }      c := &client{conn, reader, name}     r.addClient(c)     r.publishMessage(c.name + " has joined the server ")     r.handleClient(c) }  func (r *Server) handleClient(c *client) {     for {         msg, e := c.reader.ReadString(' ')         if e == io.EOF {             c.conn.Close()             r.deleteClient(c)             r.publishMessage(c.name + " has left the server ")             return         } else if e != nil {             continue         }         r.messageChan <- message{c.name + ": " + msg, c}     } }  func (r *Server) sendMessages() {     for {         m := <-r.messageChan         r.m.RLock()         for k := range r.clients {             if k != m.client {                 go k.conn.Write([]byte(m.message))             }         }         r.m.RUnlock()         fmt.Print(m.message)     } }  func (r *Server) publishMessage(msg string) {     r.m.RLock()     for k := range r.clients {         go k.conn.Write([]byte(msg))     }     r.m.RUnlock()     fmt.Print(msg) }  func (r *Server) addClient(c *client) {     r.m.Lock()     r.clients[c] = struct{}{}     r.m.Unlock() }  func (r *Server) deleteClient(c *client) {     r.m.Lock()     delete(r.clients, c)     r.m.Unlock() }   

Apreciaría si alguien pudiera dar algunas sugerencias / comentarios.

Original en ingles

I've never done concurrent programming before, but I've written one using go channels and RWMutex, but I'm not sure if my approach is idiomatic. I feel like it could all use channels (Maybe).

The protocol is simple: Messages are delimited by '\n'. The first message client sends is it's name. The rest are normal messages. I used a map because it's add + delete is faster, and because I'm not concerned about the ordering. I'm mostly concerned with the locks at the last 4 methods. Could I use channels here instead? Am I using locks correctly?

type message struct {     message string     client  *client }  type client struct {     conn   net.Conn     reader *bufio.Reader     name   string }  type Server struct {     l           net.Listener     clients     map[*client]struct{}     messageChan chan message     port        uint16     name        string     m           sync.RWMutex }  // NewServer starts listening, and returns an initialized server. If an error occured while listening started, (nil, error) is returned. func NewServer(name string, port uint16) (*Server, error) {     p := strconv.FormatUint(uint64(port), 10)     l, e := net.Listen("tcp", "localhost:"+p)     if e != nil {         return nil, e     }     return &Server{l, make(map[*client]struct{}), make(chan message), port, name, sync.RWMutex{}}, nil }  // Start starts accepting clients + managing messages. func (r *Server) Start() {     go r.sendMessages()     for {         conn, e := r.l.Accept()         if e != nil {             continue         }         go r.handleConn(conn)     } }  func (r *Server) handleConn(conn net.Conn) {     reader := bufio.NewReader(conn)     name, e := reader.ReadString('\n')     name = strings.TrimSpace(name)     if e != nil {         conn.Close()         return     }      c := &client{conn, reader, name}     r.addClient(c)     r.publishMessage(c.name + " has joined the server\n")     r.handleClient(c) }  func (r *Server) handleClient(c *client) {     for {         msg, e := c.reader.ReadString('\n')         if e == io.EOF {             c.conn.Close()             r.deleteClient(c)             r.publishMessage(c.name + " has left the server\n")             return         } else if e != nil {             continue         }         r.messageChan <- message{c.name + ": " + msg, c}     } }  func (r *Server) sendMessages() {     for {         m := <-r.messageChan         r.m.RLock()         for k := range r.clients {             if k != m.client {                 go k.conn.Write([]byte(m.message))             }         }         r.m.RUnlock()         fmt.Print(m.message)     } }  func (r *Server) publishMessage(msg string) {     r.m.RLock()     for k := range r.clients {         go k.conn.Write([]byte(msg))     }     r.m.RUnlock()     fmt.Print(msg) }  func (r *Server) addClient(c *client) {     r.m.Lock()     r.clients[c] = struct{}{}     r.m.Unlock() }  func (r *Server) deleteClient(c *client) {     r.m.Lock()     delete(r.clients, c)     r.m.Unlock() } 

I would appreciate if anyone could give some suggestions/feedback.

        

Lista de respuestas

4
 
vote
vote
La mejor respuesta
 

Este es un buen proyecto para aprender la concurrencia, y ya ha identificado algunos problemas que le preocupan, y usted tiene razón en algunos aspectos. Go Documentation recomienda usar canales cuando sea posible, sobre mutexes. La documentación en el paquete dice: "que no sea Los tipos de la biblioteca de biblioteca de bajo nivel y de espera, la mayoría, la mayoría está diseñada para su uso. La sincronización de nivel superior se realiza mejor a través de canales y comunicación ".

Los canales son su amigo, y usted debe usarlos generosamente (bueno, más generoso que mutexes).

Sin embargo,

Hay algunos casos especiales con canales, y su código tiene un buen ejemplo de cómo se pueden usar de forma rota. Tu concurrencia no es tan significativa como crees que es. Me centraré en este código por el momento, y lo simplificaremos, y luego volveremos al problema de la concurrencia en un poco ...

Manipulador de clientes

este código ....

  ptr6  

... toma una conexión de red de clientes, tire del nombre y luego establece un bucle para leer y distribuir mensajes. Distribuye los mensajes presionándolos al canal ptr7 . Simplifiquemos ese código todo un grupo, y señale algunos problemas a medida que vayamos.

primero, debe aprender la declaración ptr828 y usarla para cerrar la conexión del cliente. Si cambia la primera línea de la función para ser:

  ptr9  

Ahora no tiene que preocuparse por cerrarlo en varios otros lugares. Simplemente regresar de la función es suficiente para cerrarlo.

En segundo lugar, debe usar un escáner ... no un lector. ver: 99887776655443330 que tiene el escáner de documentación " Proporciona una interfaz conveniente para leer datos, como un archivo de líneas de texto delimitadas de nueva línea ". El escáner se refiere a la terminación de línea (que es lo que tiene), por lo que es fácil configurar. Su código:

  p1  

se convierte en:

  p2  

Nota que el escáner nunca devuelve un error p33 , solo devuelve un 998877666554433344 escanear en EOF (o cualquier otro error). La ventaja para usted de un escáner es que simplifica sus bucles y el manejo de errores. Esto no es obvio de inmediato en su caso, ya que escanea el p5 , pero se hará evidente al procesar mensajes. Para obtener el nombre, necesita:

  p6  

OK, así es como recibe el nombre ... y ahora podemos crear el cliente (usando el escáner en lugar del lector) y también usamos una función de diferenciador para eliminar el cliente en lugar de cambiar la lógica en algún otro Lugar (manteniendo el código de limpieza en el mismo lugar que el código de desorden es importante para la mantenibilidad). Además, debemos reubicar los métodos "Publicar" en el p7 y 998877665554433388 Methods (donde realmente deberían estar de todos modos):

  p9  

OK, así que ahora tenemos un cliente que puede procesar mensajes entrantes, vamos a ver ese código, pero use un escáner ahora en lugar de un lector, y no necesita hacer el 99887776655443340 o La "publicación" relacionada de la eliminación. Tampoco necesita cerrar la conexión:

  ptr1  

Ahora, eso se ve limpio ... tan pequeño ahora que apenas vale la pena tener su propia función, volvamos a la función 99887766655443342 Cambiaremos a ptr3 < / Código> Porque prefiero el cliente ... y eliminaremos el ptr4 desde el ptr5 struct porque nadie más lo usa en cualquier otro lugar ... nuestro ptr6 MÉTODO es ahora:

  ptr7  

Ahora, eso es un grupo más simple, especialmente el manejo de errores reducido y el contenido reducido en el cliente.

Hubo algunas cosas para martillar aquí. Traslamos la impresión de mensajes como ptr8 ptr9 en el y struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr) - sizeof(struct memoryBlock)); 1 Métodos respectivamente. Esto reduce la lógica mixta de manejar a un cliente y también la actividad de informes en esta función, y lo cambia al lugar más lógico donde realmente estamos agregando y eliminando las cosas. Usamos las funciones de diferencias para manejar la limpieza de los recursos al mismo tiempo / lugar que los recursos que E creado. Utilizamos un escáner en lugar de un lector.

Messagechan

Copulemos ese tema de concurrencia que mencioné anteriormente. Se reduce al struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr) - sizeof(struct memoryBlock)); 2 . El problema es que solo un cliente está ocupado a la vez. Déjame explicarlo ... este código aquí:

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 3  

empuja los mensajes en el canal, y este código los elimina:

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 4  

Pensaría que todos los clientes podrían estar presionando los mensajes, y el BOOP lo está tirando y distribuirlos al mismo tiempo. Desafortunadamente, todos los clientes "bloquean" en el punto en el que presionan el mensaje, y todos esperan allí, hasta que comience el bucle para el bucle, y tirará solo un mensaje. Ese mensaje se procesará, y el cliente que sucedió en su mensaje tirado, luego se apagará y esperará otro mensaje: todos los otros clientes todavía están bloqueados. Una vez que el BOOP haya enviado el mensaje, volverá y leerá otro desde el canal, y otro cliente tendrá la suerte de tener su mensaje entregado.

Esto se debe a que ha creado un canal de sin fondos . El La documentación tiene esto para decir : "Si la capacidad es cero o ausente, la El canal está sin problemas y la comunicación solo tiene éxito cuando tanto un remitente como el receptor están listos ". Dado que el receptor solo está listo una vez que se listo una vez en cada bucle, el receptor esencialmente serializa a todos los clientes.

Debe declarar su canal con algún tamaño de búfer si desea evitar este problema. Incluso un tamaño de 1 sería beneficioso. Un tampón más grande puede resultar en el almacenamiento en caché de mensajes, pero eso no debe ser un problema.

Comunicación en lugar de mutexes

La comunicación en Go es un concepto extraño para explicar. El propósito de los mutexes es permitir un acceso seguro a los recursos de múltiples rutinas Go-. La comunicación es una especie de lo contrario: la comunicación básicamente implica que solo una rutina de Go-way accede a un recurso, por lo que no se necesitan mutexes, pero podemos comunicar los cambios (normalmente a través de un canal) a esa rutina Go-rutina.

Si solo una rutina accede a un recurso, no se necesita mutex. Veamos cómo podemos aplicar eso a su código. El código de "servidor" realmente tiene tres cosas en marcha ...

  1. Clientes se agregan
  2. Los mensajes se distribuyen
  3. los clientes se eliminan.

Ya tiene un canal para los mensajes, así que veamos las otras partes ...

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 5  

Vamos a encender los canales en lugar de:

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 6  

Tendremos que crear esos canales (con un poco de búfer) en el struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr) - sizeof(struct memoryBlock)); 7 ... pero así es como comunicamos el agregar / eliminar del cliente.

¿Cómo manejamos eso en el servidor? Actualmente se ve el código del servidor:

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 8  

Si escuchamos la comunicación de todos los canales, no solo el Messagechan, podemos hacerlo todo en esa rutina (y sin necesidad de mutexes porque somos los únicos para manipular el mapa del cliente.

  struct memoryBlock *p = (struct memoryBlock*) (((char*) ptr)         - sizeof(struct memoryBlock)); 9  

Ahora podemos eliminar el mapa if6554433606554433606554433606554433606655443361 SendMessages` Go-rutuine.

LEER EN EL CÓDIGO if2 que es cómo se maneja la comunicación de múltiples fuentes: https://golang.org/ref/spec#select_statements

Conclusión

Esta publicación ya ha sido lo suficientemente largometrada ... espero que tengas algo fuera de eso. Espero que al menos obtenga cómo los canales sin fundamento pueden afectar las cosas, y esa comunicación con las declaraciones seleccionadas le permite localizar recursos a una sola rutina, lo que significa que no se necesitan mutexes.

 

This is a nice project to learn concurrency on, and you've already identified a few issues that concern you, and you're right in some ways. Go documentation recommends using channels when possible, over mutexes. The documentation in the sync package says: "Other than the Once and WaitGroup types, most are intended for use by low-level library routines. Higher-level synchronization is better done via channels and communication."

Channels are your friend in Go, and you should use them liberally (well, more liberally than mutexes).

There are some special cases with channels, though, and your code has a good example of how they can be used in a broken way. Your concurrency is not nearly as significant as you think it is. I will focus on this code for the moment, and we'll simplify it down, and then get back to the concurrency problem in a bit...

Client handler

This code....

func (r *Server) handleConn(conn net.Conn) {     reader := bufio.NewReader(conn)     name, e := reader.ReadString('\n')     name = strings.TrimSpace(name)     if e != nil {         conn.Close()         return     }      c := &client{conn, reader, name}     r.addClient(c)     r.publishMessage(c.name + " has joined the server\n")     r.handleClient(c) }  func (r *Server) handleClient(c *client) {     for {         msg, e := c.reader.ReadString('\n')         if e == io.EOF {             c.conn.Close()             r.deleteClient(c)             r.publishMessage(c.name + " has left the server\n")             return         } else if e != nil {             continue         }         r.messageChan <- message{c.name + ": " + msg, c}     } } 

... takes a client network connection, pulls the name, and then establishes a loop for reading and distributing messages. It distributes the messages by pushing them on to the r.messageChan channel. Let's simplify that code a whole bunch, and point out some problems as we go.

First up, you need to learn the defer statement, and use it for closing the client connection. If you change the first line of the function to be:

func (r *Server) handleConn(conn net.Conn) {     defer conn.Close() 

now you don't need to worry about closing it in a number of other places. Just returning from the function is enough to close it.

Secondly, you should use a scanner... not a Reader. See: Scanner which has the documentation "Scanner provides a convenient interface for reading data such as a file of newline-delimited lines of text." Scanner defaults to using line-termination (which is what you have) so it's easy to set up. Your code:

    reader := bufio.NewReader(conn) 

becomes:

     scanner := bufio.NewScanner(conn) 

Note that Scanner never returns an EOF error, it just returns a false scan at EOF (or any other error). The advantage for you of a scanner is that it simplifies your loops, and error handling. This is not obvious immediately in your case as you scan the name, but will become apparent when processing messages. To get the name, you need:

// find the next end-of-line.. (or perhaps EOF) if !scanner.Scan() {     // could not scan anything on the first scan... odd... no name.     // the scanner.Err() may have a value if there's a problem, but we don't care.     return } name := strings.TrimSpace(scanner.Text()) 

OK, that's how you get the name.... and now we can create the client (using the scanner instead of reader) and we also use a defer function to remove the client instead of shifting that logic in to some other place (keeping the clean-up code in the same place as the mess-up code is important for maintainability). Also, we should relocate the "publish" methods in to the addClient and deleteClient methods (where they really should be anyway):

c := &client{conn, scanner, name} r.addClient(c) defer r.deleteClient(c) r.handleClient(c) 

OK, so now we have a client that can process incoming messages, let's look at that code, but using a scanner now instead of a reader, and it does not need to do the deleteClient or the related "publish" of the deletion. It also does not need to close the connection:

func (r *Server) handleClient(c *client) {     for c.scanner.Scan() {         msg := c.scanner.Text()         r.messageChan <- message{c.name + ": " + msg, c}     }     // we could check scanner.Err() here, but we are ignoring all non-EOF errors anyway } 

Now, that looks neat... so small now that it's hardly worth having it's own function, let's pull it back in to the handleConn function (which we will rename to handleClient because I prefer client... and we'll remove the scanner from the client struct because no-one else uses it in any other place... our handleClient method is now:

func (r *Server) handleClient(conn net.Conn) {     defer conn.Close()      scanner := bufio.NewScanner(conn)      // read off the first line as the "name"      // find the next end-of-line.. (or perhaps EOF)     if !scanner.Scan() {         // could not scan anything on the first scan... odd... no name.         // the scanner.Err() may have a value if there's a problem, but we don't care.         return     }     name := strings.TrimSpace(scanner.Text())      // register our client in to the control systems.     c := &client{conn, name}     r.addClient(c)     defer r.deleteClient(c)      // send messages in to the channel.     for scanner.Scan() {         r.messageChan <- message{name + ": " + scanner.Text(), c}     } } 

Now, that's a bunch simpler, especially the reduced error handling, and the reduced content in the client.

There were a few things to hammer out here. We shifted the printing of messages like X has joined the server and X has left the server in to the addClient and deleteClient methods respectively. This reduces the mixed logic of handling a client and also reporting activity in this function, and shifts it to the more logical place where we are actually adding and deleting things. We used defer functions to handle cleanup of resources at the same time/place that the resources were created. We used a scanner instead of a reader.

messageChan

Let's cover that concurrency issue I mentioned earlier. It comes down to the messageChan. The problem is only one client is busy at a time. Let me explain... this code here:

r.messageChan <- message{name + ": " + scanner.Text(), c} 

pushes messages on to the channel, and this code pulls them off:

for {     m := <-r.messageChan     .... distribute the messages to other clients } 

You would think that multiple clients could all be pushing messages, and the for-loop is pulling them and distributing them at the same time. Unfortunately, all the clients "block" at the point where they push the message, and they all wait there, until the for-loop starts, and it will pull just one message. That one message will be processed, and the client that happened to have its message pulled, will then go off and wait for another message - all the other clients are still blocked. Once the for loop has sent the message off, it will come back and read another from the channel, and one other client will be lucky enough to have its message delivered.

This is because you have created an unbuffered channel. The documentation has this to say: "If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready." Since the receiver is only ready once each loop, the receiver essentially serializes all the clients.

You need to declare your channel with some buffer size if you want to avoid this issue. Even a size of 1 would be beneficial. A larger buffer may result in caching of messages but that should not be a problem.

Communication instead of Mutexes

Communication in Go is an odd concept to explain. The purpose of mutexes is to allow safe access to resources from multiple go-routines. Communication is sort of the opposite - communication basically implies only one go-routine ever accesses a resource, so mutexes are not needed, but we can communicate the changes (normally via a channel) to that go-routine.

If only one routine accesses a resource, no mutex is needed. Let's see how we can apply that to your code. Your "server" code really has three things going on...

  1. clients get added
  2. messages get distributed
  3. clients get removed.

You've already got a channel for the messages, so let's look at the other parts....

func (r *Server) addClient(c *client) {     r.m.Lock()     r.clients[c] = struct{}{}     r.m.Unlock() }  func (r *Server) deleteClient(c *client) {     r.m.Lock()     delete(r.clients, c)     r.m.Unlock() } 

Let's turn those in to channels instead:

func (r *Server) addClient(c *client) {     r.added <- c }  func (r *Server) deleteClient(c *client) {     r.deleted <- c } 

We will have to create those channels (with some buffering) on the Server... but, that's how we communicate the add/remove from the client.

How do we handle that on the server? Currently the server code looks like:

func (r *Server) sendMessages() {     for {         m := <-r.messageChan         r.m.RLock()         for k := range r.clients {             if k != m.client {                 go k.conn.Write([]byte(m.message))             }         }         r.m.RUnlock()         fmt.Print(m.message)     } } 

If we listen for communication from all channels, not just the messageChan, we can do it all in that routine (and no need for mutexes because we're the only ones to manipulate the client map.

func (r *Server) sendMessages() {     for {         select {         case m := <-r.messageChan:             for k := range r.clients {                 if k != m.client {                     go k.conn.Write([]byte(m.message))                 }             }             fmt.Print(m.message)         case c := <-r.added:             r.publishMessage(c.name + " has joined the server\n")             r.clients[c] = struct{}{}         case c := <-r.deleted:             r.publishMessage(c.name + " has left the server\n")             delete(r.clients, c)         }     } } 

Now we can even remove the client map from the Server completely, and keep it local inside thesendMessages` go-routine.

Read up on the select statement which is how communication from multiple sources is handled: https://golang.org/ref/spec#Select_statements

Conclusion

This post has been long enough already... hopefully you'll get something out of it. I hope that you at least get how the unbuffered channels can impact things, and that communication with select-statements allows you to localize resources to just one routine, meaning mutexes are not needed.

 
 
     
     

Relacionados problema

1  Función para bloquear un archivo usando Memcache, versión 1  ( Function to lock a file using memcache version 1 ) 
Tengo una aplicación web donde enumera los archivos en la UI a muchos usuarios al mismo tiempo. Hay muchas personas que monitorean los archivos y los procesan...

6  Usando el temporizador con el trabajador de fondo para asegurarse de que el método Dowork se llame  ( Using timer with backgroundworker to ensure the dowork method is called ) 
Tengo una aplicación de formularios de Windows en la que un trabajador de fondo se llama una y otra vez. Necesito evitar el acceso concurrente del código en D...

7  Esperando la conexión del servidor de juegos  ( Waiting for game server connection ) 
public boolean connectedOnGameServer = false; public final Object conGameServerMonitor = new Object(); public void connectedToGameServer() { synchronize...

2  Función para bloquear un archivo usando Memcache, versión 2  ( Function to lock a file using memcache version 2 ) 
Basado en comentarios sugeridos para Función para bloquear un archivo usando Memcache < / a>, he modificado el código a import os import shutil import mem...

3  Simulando Memcachache Obtén y establece la condición de carrera y resolviéndola con Agregar  ( Simulating memcache get and set race condition and solving it with add ) 
Memcache get y set puede llegar a una condición de carrera si los usa juntos para lograr algo como el bloqueo porque no es atómico . Un simple memcache ...

2  STD :: Lock Implementación en C con PTHEADS  ( Stdlock implementation in c with pthreads ) 
Me estropeé un poco con PTHEADS y necesitaba una alternativa a la función C ++ 11 std::lock (2 args son suficientes), y esto es lo que vino: void lock(pt...

48  Safe-Safe y Lock-Free - Implementación de la cola  ( Thread safe and lock free queue implementation ) 
Estaba tratando de crear una implementación de colas de bloqueo en Java, principalmente para el aprendizaje personal. La cola debe ser general, lo que permite...

8  C ++ Sección crítica con tiempo de espera  ( C critical section with timeout ) 
Nota: Mi implementación se basa en CodeProject Artículo de Vladislav Gelfer. Basado en valdok 's CÓDIGOS DE VALDOK , reescribí la clase de sección crí...

2  Método de actualización de bloqueo de reentrantreadwritelock  ( Reentrantreadwritelock lock upgrade method ) 
Tengo una pregunta sobre la actualización de bloqueo. Específicamente lo que me molesta está entre readlock.unlock () y siguiendo a writelock.lock () ... Esto...

19  Una clase de hilo-piscina / cola personalizada  ( A custom thread pool queue class ) 
Quería una clase que ejecuta cualquier cantidad de tareas, pero solo cierta cantidad al mismo tiempo (por ejemplo, para descargar varios contenidos de Interne...




© 2022 respuesta.top Reservados todos los derechos. Centro de preguntas y respuestas reservados todos los derechos