Trabajo Práctico N°4 Implementación de cliente y servidor TCP (y UDP) en C# (.NET), utilización de hilos para el manejo asincrónico de las comunicaciones Universidad de Buenos Aires Facultad de Ingeniería 66.48 Seminario de Electrónica José Carlos León Degreef Javier Luiso Diego Morana Gisela Schmilchu Índice Índice ............................................................................................................................ 1 Resumen ....................................................................................................................... 3 La diferencia entre comunicación sincrónica y asincrónica en programación de redes3 Clase Socket ................................................................................................................. 4 Aplicación TCP ............................................................................................................ 5 Implementación del Socket Server ........................................................................... 6 Implementación del Socket Client............................................................................ 9 Como encontrar que cliente envió un mensaje particular....................................... 10 Como responder o enviar mensajes a clientes específicos ..................................... 12 Como saber cuando un cliente en particular se desconecta.................................... 13 Como obtener la lista de los clientes conectados en cualquier momento............... 14 Aplicación para UDP.................................................................................................. 15 Server...................................................................................................................... 16 Cliente..................................................................................................................... 18 Temas de Threading a considerar............................................................................... 21 1 - Las variables son seguras en métodos AsynCallBack? Y los hilos de sincronización? ....................................................................................................... 21 2 - Shared variables ................................................................................................ 21 3 - Modificando la interfaz GUI ............................................................................. 22 2 Resumen El objetivo es demostrar que una aplicación de tipo cliente – servidor basada en sockets que permita la comunicación asincrónica usa hilos a nivel sistema operativo. El servidor no usa hilos para comunicarse con los múltiples clientes, pero internamente sí lo hace, gracias al framework de .Net y a C#. La diferencia entre comunicación sincrónica y asincrónica en programación de redes Consideremos una aplicación servidor que esta escuchando en un puerto específico para recibir datos de clientes. En el método sincrónico, mientras el servidor está esperando para recibir datos de un cliente, si el “stream” o chorro de datos esta vació, el hilo principal o “main thread” se bloqueará hasta que el pedido de datos sea satisfecho. Por lo tanto, el servidor no podrá hacer nada hasta que reciba los datos del cliente. Si otro cliente tratara de conectarse al servidor al mismo tiempo, el servidor no podrá procesar el pedido de conexión porque estará bloqueado por el primer cliente. Esta situación no es aceptable en nuestro caso ni en el mundo real, donde necesitamos soportar múltiples clientes al mismo tiempo. En una comunicación asincrónica, cuando el servidor esta escuchando o recibiendo datos de un cliente, puede procesar pedidos de otros clientes como así también recibir datos de esos clientes. Cuando el servidor esta recibiendo asincrónicamente, un hilo diferente (a nivel sistema operativo) escucha el socket e invoca una función callback (especificada cuando comenzó la recepción asincrónica) cuando ocurra el evento en el socket. Esta función callback responderá y procesará el evento del socket. Por ejemplo, si el programa remoto escribe datos en el socket, un evento de lectura de datos (función callback que hayamos especificado) será invocado; el mismo sabrá como leer datos del socket en ese punto. Todo esto puede ser resuelto con múltiples hilos, pero deberíamos aprovechar el framework de .Net y C# que nos proveen un conjunto de funcionalidades para lograr la comunicación asincrónica y así obviar la complejidad del uso de los hilos. 3 Clase Socket La clase Socket (System.Net.Sockets.Socket) provee un conjunto de métodos para comunicaciones sincrónicas y asincrónicas. En la convención de .Net, todos los nombres de los métodos asincrónicos son creados prefijando con “Begin” o “End” al nombre del método sincrónico. Los métodos prefijados con “Begin” y “End” representan un par de métodos asincrónicos correspondientes a un solo método sincrónico, como podemos ver en la siguiente tabla. Métodos Sincrónicos Connect() Métodos Asincrónicos BeginConnect() Receive() EndConnect BeginReceive() EndReceive() 4 Aplicación para TCP La implementamos a través de dos clases, una implementando al servidor socket y la otra al cliente socket. 5 Implementación del Socket Server La aplicación Socket Server, esta implementada con en la clase SocketServer (archivo SocketServer.cs). Esta clase tiene un objeto socket principal (socketPrincipal) y un ArrayList, tal como se muestra en el siguiente código. (Un HashTable también funcionaría si quisiéramos usar un string en lugar de un índice para acceder a los clientes conectados). Importante: Si queremos correr el servidor por un tiempo de duración infinito, existe la posibilidad de overflow del valor entero de la variable contadorCliente. En esos casos, deberíamos y tendríamos que reconsiderar usar este esquema de numeración para los clientes. // Usamos un ArrayList para mantener la lista de Sockets activos que son // designados para comunicarse con cada cliente conectado private ArrayList socketActivos = new ArrayList(); // La siguiente variable mantiene el total de clientes conectados en // cualquier momento private int contadorCliente = 0; El objeto socketPrincipal escucha por clientes. Una vez que un cliente se conecta, el objeto Socket principal transfiere la responsabilidad de procesamiento de las transacciones relacionadas para ese cliente particular a un socket activo. Luego el objeto socketPrincipal vuelve y continúa escuchando por otros clientes. BeginAccept() y BeginReceive() son dos métodos importantes en la clase Socket, usados por la aplicación Socket Server. 6 El método BeginAccept() tiene la siguiente nomenclatura: public IAsyncResult BeginAccept( AsyncCallback callback, object state // (1) función a llamar cuando un cliente se // se conecta // (2) State object para preserver la info // del socket ); Esencialmente, después de llamar al método Listen() del objeto principal Socket, llamamos a este método asincrónico y especificamos la función callback (1), que designamos para hacer el procesamiento relacionado con la conexión del cliente. El state object (2) puede ser nulo en esta instancia particular. Al ser un método asincrónico, este volverá inmediatamente y el hilo principal del servidor estará libre para procesar eventos. En la realidad, un hilo separado comenzará a escuchar en ese socket particular por conexiones de clientes. Cuando un cliente requiera un pedido de conexión, la función callback que hayamos especificado será invocada. Dentro de esta función callback (en el ejemplo, la función se llama “onClientConnect()”), se realizarán los procesos correspondientes a la conexión del cliente. public void OnClientConnect(IAsyncResult asyn) { try { // Aqui completamos/terminamos la llamada asincronica BeginAccept() // llamando a EndAccept() que devuelve la referencia a un nuevo // objeto Socket socketActivo[contadorClientes] = socketPrincipal.EndAccept (asyn); // Incrementamos el contador de clientes para este cliente // de manera cuidadosa a traves de la clase Interlocked Interlocked.Increment(ref contadorClientes); // Agregamos al ArrayList la referencia al socket activo socketActivos.Add(socketActivo); // Envio bienvenida al cliente string msg = "Bienvenido cliente " + contadorClientes + "\n"; EnviarMsg(msg, contadorClientes); // Actualizo la lista de clientes (thread safe call) ActualizarControlListaClientes(); // Dejamos al Socket Activo hacer el proceso correspondiente para // el cliente recien conectado EsperarPorDatos(socketActivo, contadorClientes); // Como el Socket principal esta ahora libre, puede volver y // esperar por otros clientes tratando de conectarse socketPrincipal.BeginAccept(new AsyncCallback ( OnClientConnect ),null); } catch(ObjectDisposedException) { System.Diagnostics.Debugger.Log(0,"1","\n OnClientConnection: Se ha cerrado el socket\n"); 7 } catch(SocketException se) { MessageBox.Show ( se.Message ); } } Lo primero que hacemos dentro de la función “OnClientConnect()” es llamar al método EndAccept() en el miembro del objeto socketPrincipal, que devolverá una referencia a otro objeto Socket. Incrementamos el contador de clientes mediante la clase Interlocked. Seteamos la referencia del objeto (socketActivo) al arrayList (socketActivos). Ahora, al tener la referencia a un nuevo objeto Socket que puede procesar la transacción con el cliente, el Socket principal (socketPrincipal) esta libre; luego, llamamos a su método BeginAccept() nuevamente para comenzar la espera de pedidos por parte de otros clientes. En el socket activo, usamos una estrategia similar para recibir datos del cliente. En lugar de llamar a BeginAccept() y a EndAccept(), aquí llamamos a BeginReceive() y a EndReceive(). Esto, en resumen, es la implementación de un Servidor Socket. Cuando enviamos datos al cliente, el servidor simplemente usa el objeto Socket activo correspondiente para enviar datos a cada cliente. 8 Implementación del Socket Client La aplicación Socket Cliente esta implementada en la clase SocketClient (archivo SocketClient.cs). En comparación con el servidor, donde teníamos al Socket y al arreglo de sockets activos, aquí solo tenemos un objeto Socket (socketCliente). Los dos métodos importantes en la clase Socket usados por la aplicación cliente son Connect() y BeginReceive(). Connect() es un método sincrónico y es llamado para conectarse al servidor que esta escuchando pedidos de conexión por parte de los clientes. Como esta llamada será satisfecha o no inmediatamente, dependiendo de si hay un servidor activo esperando conexiones o no en la IP especificada y el puerto especificados, basta con que sea un método sincrónico para nuestro propósito. Una vez establecida la conexión, llamamos a la función asincrónica BeginReceive() para esperar por cualquier actividad de escritura en el socket por parte del servidor. Aquí, si llamáramos a un método sincrónico, el hilo principal en la aplicación cliente será bloqueado y no será capaz de enviar ningún dato al servidor mientras el cliente esta esperando por datos desde el servidor. Cuando haya cualquier actividad de escritura en el socket desde el servidor, el hilo interno iniciado por BeginReceive() invocara a la función callback (“onDataReceived()” en este caso), que se encargara del procesamiento de cualquier dato escrito por el servidor. Al momento de enviar datos al servidor, solo llamamos al método Send() en el objeto socketCliente, que escribirá datos sincrónicamente en el socket. Esto es todo en una comunicación asincrónica usando múltiples clientes. 9 Como encontrar que cliente envió un mensaje particular Cuando múltiples clientes están conectados, probablemente deberíamos diferenciar entre mensajes recibidos por diferentes clientes. Además, tal vez haya alguna razón para enviar un mensaje a un cliente particular. Esto se puede resolver manteniendo pistas de cada cliente asignándoles un número incrementado serialmente ni bien se conecten al servidor tal como se ve en el siguiente código: public void OnClientConnect(IAsyncResult asyn) { try { // Aqui completamos/terminamos la llamada asincrónica BeginAccept() // llamando a EndAccept() que devuelve la referencia a un nuevo // objeto Socket Socket socketActivo = socketPrincipal.EndAccept (asyn); // Ahora, incrementamos el contador de clientes para este cliente ++contadorClientes; // Agregamos la referencia al socket activo en el ArrayList // Usaremos (numeroCliente – 1) como el indice para acceder a este // socket en el futuro socketActivos.Add(socketActivo); //........ // Dejamos al Socket Activo hacer el proceso correspondiente para // el cliente recien conectado EsperarPorDatos(socketActivo, contadorClientes); //........ Dentro de la función EsperarPorDatos(), haremos la llamada asincrónica para recibir datos del cliente de la siguiente manera: public void EsperarPorDatos(Socket soc, int num) { try { if( pfnWorkerCallBack == null ) { // Especificamos la funcion callback que invocaremos cuando // haya alguna actividad de escritura en el cliente conectado pfnWorkerCallBack = new AsyncCallback (OnDataReceived); } SocketPacket socketPacket = new SocketPacket (soc, num); // Empieza la recepcion de datos enviados por el cliente // asincronicamente soc.BeginReceive (theSocPkt.dataBuffer, 0, theSocPkt.dataBuffer.Length, SocketFlags.None, pfnWorkerCallBack, socketPacket); //........ En el código anterior, la clase definida por el usuario SocketPacket es el ítem mas critico. Un objeto de esta clase es el ultimo parámetro pasado a la función asincrónica BeginReceive(). Este objeto contiene cualquier información que encontremos útil; puede ser usada más tarde, cuando realmente recibamos datos del cliente. Enviamos (1) el objeto Socket Activo y (2) el número de índice 10 del cliente empacado en ese objeto. Los recuperaremos cuando recibamos datos de un cliente particular. Debajo, está la definición de la clase SocketPacket. public class SocketPacket { public Socket socketActual; public int numeroCliente; // Buffer para almacenar los datos enviados por el cliente public byte[] dataBuffer = new byte[1024]; // Constructor que toma un Socket y un numero de cliente public SocketPacket(Socket socket, int numero) { socketActual = socket; numeroCliente = numero; } } En el código anterior, la clase SocketPacket contiene la referencia a un socket, un buffer de datos de un tamaño de 1024 bytes, y un número de cliente. El número de cliente estará disponible cuando empecemos a recibir datos de un cliente en particular. Usando éste número de cliente, podemos identificar que cliente esta enviando datos. Para demostrar esto, en el ejemplo, el servidor responderá al cliente (luego de convertir en mayúsculas) el mensaje recibido, usando el objeto socket correcto. 11 Como responder o enviar mensajes a clientes específicos Esto es muy simple de implementar. Como el objeto SocketPacket contiene la referencia a un socket activo particular, simplemente usamos ese objeto para responder a un cliente. Adicionalmente, podemos enviar cualquier mensaje a cualquier cliente usando el objeto Socket Activo, almacenados en el ArrayList. 12 Como saber cuando un cliente en particular se desconecta Esto es un poco más complicado. Deberíamos usar algún método más elegante, pero esta es una manera simple y fácil de comprender. Cuando un cliente se desconecte, habrá una ultima llamada a la función OnDataReceived(). Si no hace nada en particular, arrojará un SocketException. Lo que hacemos es mirar dentro de la excepción y ver si fue disparada por la “desconexión” del cliente. Para ello, observamos si el código de error dentro del objeto exception es igual a 10054. Si lo es, haremos la acción correspondiente a la desconexión del cliente. Aquí nuevamente, el objeto SocketPacket nos dará el número de cliente que se desconectó. catch(SocketException se) { if(se.ErrorCode == 10054) // Codigo de error para reseteo de // conexión por el cliente { string msg = "Cliente " + socketData.numeroCliente + " desconectado" + "\n"; txtMsgRecibidos.AppendText(msg); // Eliminamos la referencia al socket activo del cliente cerrado // así el objeto será recogido por el recolector de basura socketActivos[socketData.numeroCliente - 1] = null; ActualizarListaClientes(); } else { MessageBox.Show (se.Message ); } } 13 Como obtener la lista de los clientes conectados en cualquier momento Para ver esto, se muestra una lista dinámica en la interfaz GUI del servidor que será actualizada (ver la función ActualizarListaClientes()) cuando un cliente se conecte o desconecte. 14 Aplicación para UDP 15 Server 16 private void btnAbrirCanalUDP_Click(object sender, EventArgs e) { try { if (txtPuerto.Text == "") { MessageBox.Show("Por favor ingrese numero de puerto"); return; } socketServer = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socketServer.SetSocketOption(SocketOptionLevel.Udp, SocketOptionName.NoDelay, 1); socketServer.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1); socketServer.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.IpTimeToLive, 20); IPAddress ip = IPAddress.Parse(txtIP.Text); int iPuerto = Convert.ToInt16(txtPuerto.Text); RemoteHostIPEnd = new IPEndPoint(ip, iPuerto); RemoteEndPoint = (EndPoint)RemoteHostIPEnd; LocalHostIPEnd = new IPEndPoint(IPAddress.Any, iPuerto); LocalEndPoint = (EndPoint)LocalHostIPEnd; socketServer.Bind(LocalHostIPEnd); ActualizarControlBotones(true); EsperarPorDatos(); } catch (SocketException se) { MessageBox.Show(se.Message); } } 17 Cliente 18 void btnAbrirSocketUDP_Click(object sender, System.EventArgs e) { if (txtIP.Text == "" || txtPuerto.Text == "") { MessageBox.Show("Se requieren IP y Puerto para conectarse\n"); return; } try { ActualizarControles(false); socketCliente = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socketCliente.SetSocketOption(SocketOptionLevel.Udp, SocketOptionName.NoDelay); socketCliente.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Address); socketCliente.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.IpTimeToLive, 20); IPAddress ip = IPAddress.Parse(txtIP.Text); int iPuerto = Convert.ToInt16(txtPuerto.Text); RemoteHostIPEnd = new IPEndPoint(ip, iPuerto); RemoteEndPoint = (EndPoint)RemoteHostIPEnd; LocalHostIPEnd = new IPEndPoint(IPAddress.Any, iPuerto); LocalEndPoint = (EndPoint)LocalHostIPEnd; socketCliente.Bind(LocalHostIPEnd); ActualizarControles(true); EsperarPorDatos(); } catch (SocketException se) { string str; str = "\nFallo la conexion, esta corriendo el servidor?\n" + se.Message; MessageBox.Show(str); ActualizarControles(false); } } 19 void btnEnviar_Click(object sender, System.EventArgs e) { try { string msg = txtMsgAEnviar.Text; byte[] byData = System.Text.Encoding.ASCII.GetBytes(msg); socketCliente.BeginSendTo(byData, 0, msg.Length, SocketFlags.None, RemoteEndPoint, new AsyncCallback(SendCallback), socketCliente); } catch (SocketException se) { MessageBox.Show(se.Message); } } 20 Temas de Threading a considerar 1 - Las variables son seguras en métodos AsynCallBack? Y los hilos de sincronización? Cuando usamos llamadas asincrónicas, debemos tener en cuenta que, detrás de esto, estamos usando hilos (threads) a nivel sistema operativo para alcanzar la naturaleza asincrónica de estas llamadas. El grafico siguiente muestra una simple ilustración de la interacción de los hilos implicados en el ejemplo. En el grafico anterior, el ítem (1) es el hilo principal del GUI que se inicia cuando iniciamos la aplicación Servidor. El hilo (2) comienza cuando cualquier cliente intente conectarse al socket. El hilo (3) comienza cuando haya alguna actividad de escritura por parte de alguno de los clientes conectados. En el código de ejemplo, las funciones asincrónicas OnClientConnect() y OnDataReceived() son llamadas por hilos excepto el hilo principal del GUI. Cualquier otra función llamada dentro de estas dos funciones serán invocadas por hilos también, con excepción del hilo principal del GUI. 2 - Shared variables Cualquier variable compartida que modifiquemos en el interior del código compartido mencionado anteriormente, deberá ser protegida por estructuras de sincronización. En el ejemplo, las variables compartidas que modificamos dentro del código compartido son contadorClientes y socketActivos. 21 Podemos usar estrategias simples para proteger a estas variables. La variable contadorCliente es un entero y por lo tanto puede ser incrementada usando el método estático dentro de la clase Interlocked como se ve abajo: // Incrementamos el contador de clientes de manera correcta Interlocked.Increment(ref contadorCliente); De manera similar, protegemos la variable miembro socketActivos de modificaciones por parte de múltiples clientes al mismo tiempo, creando un Synchronized ArrayList: private ArrayList socketActivos = ArrayList.Synchronized(new ArrayList()); 3 - Modificando la interfaz GUI El hilo principal del GUI es quien realmente posee los controles del GUI. Por lo tanto, no es recomendable ni aconsejable modificar o acceder a ninguno de los controles del GUI a través de hilos, excepto que sea a través del hilo principal. Cuando necesitemos actualizar la GUI, debemos hacer que el hilo principal lo haga, como se ve en el siguiente código: // Este método puede ser llamado por el hilo principal o cualquiera // hilos activos private void ActualizarControlMsgRecibidos (string msg) { // Chequea si el método fue llamado por un hilo distinto // del hilo principal if (InvokeRequired) { // No podemos actualizar la GUI en este hilo. // Todos los controles GUI deben ser actualizados por // el hilo principal // Por lo tanto, usamos el motodo invoke en el control // que sera llamado cuando el hilo principal este libre // para actualizar a traves del hilo principal object[] pList = {msg}; txtMsgRecibidos.BeginInvoke(new ActualizarMsgRecibidos_Callback(ActualizarMsgRecibidos), pList); } else { // Es el hilo principal, por lo tanto actualize directamente ActualizarMsgRecibidos(msg); } } private void ActualizarMsgRecibidos(string msg) { txtMsgRecibidos.AppendText(msg); } 22