Socket多线程编程

作者:陈广
日期:2018-3-24


在上篇文章中,我们留下了很多问题。今天先解决服务无法侦听第二个连接的问题。想要解决这个问题,当然需要使用多线程了。这里我们先使用最原始的线程 Thread 来解决这问题。即使 Thread 已经被淘汰,但我觉得在这个场景下使用 Thread 还是挺好的,首先长线程不适合使用线程池,其次结构清晰,明了,易于理解,算是将来使用更高级异步网络编程的一个基础吧。

服务器接收多个客户端连接

首先来看上篇文章中最后一个服务器程序的流程图:

获取连接
收到消息
s.Bind
s.Accept
创建recvSocket
recvSocket.Receive
处理消息

由图可知s.Accept会阻塞进程,一旦调用,服务器就无法做其它的事了。所以这里需要专门开一个线程用于s.Accept。流程变成如下形式:

线程
获取连接
收到消息
s.Accept
创建recvSocket
recvSocket.Receive
处理消息
s.Bind
去做其它事

现在我们专门开了一个线程去执行s.Accept并处理接收消息,服务器在等待连接的同时,可以去做其它的事情了。

新的问题来了,看上图可知程序一旦接收到连接,会继续往下执行,就再也无法再回到s.Accept去接收新的连接了。而且recvSocket.Receive也会阻塞程序,也就是说recvSocket.Receives.Accept是无法在同一个线程里同时工作的。这里就需要在接收到新客户端连接时,专门再开一个线程去处理这个客户端的事情。如下图所示:

线程2
线程1
线程3
线程
获取连接
收到消息3
获取连接
收到消息1
获取连接
收到消息2
s.Accept
创建recvSocket2
recvSocket2.Receive
处理消息2
创建recvSocket1
recvSocket1.Receive
处理消息1
创建recvSocket3
recvSocket3.Receive
处理消息3
s.Bind
去做其它事

现在终于可以同时处理多个连接请求了,而且每个请求互不干扰。下面按照这个想法把代码写出来。直接在上一篇文章的基础上修改代码。

添加以下命名空间:

using System.Threading;

更改代码如下:

static void Main(string[] args)
{
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    IPEndPoint point = new IPEndPoint(ip, 5000);
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

    s.Bind(point);
    s.Listen(5);
    Console.WriteLine("服务器开始侦听...");
    //创建线程去监听客户端连接
    Thread listenThread = new Thread(() => ListenConnect(s));
    listenThread.IsBackground = true; //一定要设置为后台线程,否则程序无法关闭
    listenThread.Start();

    Console.ReadLine();
}
//监听客户端连接的线程方法
static void ListenConnect(Socket s)
{
    while (true)
    {
        Socket recvSocket = s.Accept();
        Console.WriteLine("获取一个来自{0}的连接", recvSocket.RemoteEndPoint.ToString());
        //创建专门的线程去监听客户端发送信息请求
        Thread receiveThread = new Thread(() => ReceiveMessage(recvSocket));
        receiveThread.IsBackground = true;
        receiveThread.Start();
    }
}
//接收指定客户端发送信息的线程
static void ReceiveMessage(Socket recvSocket)
{
    string ip = recvSocket.RemoteEndPoint.ToString();
    byte[] buff = new byte[1024]; //创建一个接收缓冲区
    try
    {
        while (true)
        {
            int count = recvSocket.Receive(buff, buff.Length, SocketFlags.None);
            string recvStr = Encoding.Unicode.GetString(buff, 0, count);
            Console.WriteLine("接收到来自{0}数据:{1}", ip, recvStr);
        }
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
    finally
    {
        recvSocket.Close();//客户端关闭时会引发异常,此时关闭此连接
        Console.WriteLine("{0} 已退出连接。", ip);
    }
}

先运行服务器,然后找到上篇文章最后的客户端程序所生成的.exe文件,打开多个客户端,运行效果如下:

我是先后打开三个客户端,然后提前关闭了第一个打开的客户端。

控制台版聊天程序

学了这么多,来练练手,做个控制台版聊天程序。当然,第一个聊天程序,一切重简,只允许一个服务器和一个客户端之间进行在聊天,任何一端即可发信息,也可收信息。

服务器程序

由于服务器只接收一个客户端连接,所以无需专门开一个线程专门侦听客户端连接。在侦听到连接之后,由于接收消息会阻塞程序运行,则需要开一个线程接收消息,开另一个线程发送消息。

修改服务器端代码如下:

static void Main(string[] args)
{
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    IPEndPoint point = new IPEndPoint(ip, 5000);
    Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    s.Bind(point);
    s.Listen(5);
    Console.WriteLine("服务器开始侦听...");
    Socket subSocket = s.Accept();
    Console.WriteLine("获取来自{0}的连接", subSocket.RemoteEndPoint.ToString());
    //接收消息线程
    Thread tRecv = new Thread(() => ReceiveMessage(subSocket));
    tRecv.Start();
    //发送消息线程
    Thread tSend = new Thread(() => SendMessage(subSocket));
    tSend.Start();
}
//接收消息的线程方法
static void ReceiveMessage(Socket recvSocket)
{
    string ip = recvSocket.RemoteEndPoint.ToString();
    byte[] buff = new byte[1024]; //创建一个接收缓冲区
    try
    {
        while (true)
        {
            int count = recvSocket.Receive(buff, buff.Length, SocketFlags.None);
            string recvStr = Encoding.Unicode.GetString(buff, 0, count);
            Console.WriteLine("接收到来自{0}数据:{1}", ip, recvStr);
        }
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
    finally
    {
        recvSocket.Close();
        Console.WriteLine("{0} 已退出连接。", ip);
    }
}
//发送消息的线程方法
static void SendMessage(Socket subSocket)
{
    while (true)
    {
        string sendStr = Console.ReadLine();
        byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);
        subSocket.Send(sendBuff, sendBuff.Length, SocketFlags.None);
    }
}

这里需要注意的是线程不能设置为后台线程,否则程序会提前退出。现在的处理方法也不完善,仅作为演示。

客户端代码

客户端代码除了连接部分不一样,其余和服务器端代码类似。

修改客户端代码如下:

static void Main(string[] args)
{
    //获取服务器端IP地址
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    try
    {
        Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        s.Connect(ip, 5000); //向服务器发起连接
        Console.WriteLine("开始连接服务器 {0} ...", ip.ToString());
        if(s.Connected)
        {
            Console.WriteLine("连接成功!");
        }
        //发送消息线程
        Thread tSend = new Thread(() => SendMessage(s));
        tSend.Start();
        //接收消息线程
        Thread tRecv = new Thread(() => ReceiveMessage(s));
        tRecv.Start();
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
}
//发送消息线程方法
static void SendMessage(Socket s)
{
    while (true)
    {
        string sendStr = Console.ReadLine();
        byte[] sendBuff = Encoding.Unicode.GetBytes(sendStr);
        s.Send(sendBuff, sendBuff.Length, SocketFlags.None);
    }
}
//接收消息线程方法
static void ReceiveMessage(Socket s)
{
    byte[] recvBuff = new byte[1024];
    try
    {
        while (true)
        {
            int count = s.Receive(recvBuff, recvBuff.Length, SocketFlags.None);
            string recvStr = Encoding.Unicode.GetString(recvBuff, 0, count);
            Console.WriteLine("收到服务器信息:{0}", recvStr);
        }
    }
    catch(Exception e)
    {
        Console.WriteLine(e.Message);
    }
    finally
    {
        s.Close();
        Console.WriteLine("服务器已经退出连接。");
    }
}

先运行服务器代码,再运行客户端代码,效果如下:

Windows应用程序版聊天程序

控制台版聊天程序界面非常不友好,用起来不是那么地爽,这个程序只是让大家能够了解聊天程序的大概流程而已。接下来我们做一个Windows应用程序版本的,当然,也只接受一对一聊天,简单为主。

服务器程序

先做服务器端,新建一个Windows应用程序项目,拖放如下图所的控件:

下表是各控件属性设置:

控件类型 控件命名 需设置的属性
TextBox txtRecv Multiline:True
ScrollBars:Vertial
TextBox txtSend
Button btnSend Text:发送

引用如下命名空间:

using System;
using System.Net;
using System.Net.Sockets;
using System.Windows.Forms;
using System.Threading;
using System.Text;

双击【发送】按钮生成按钮事件;生成txtSendKeyDown事件;生成窗体的Load事件。最终代码如下:

Socket serverSocket;
Socket subSocket;

private void Form1_Load(object sender, EventArgs e)
{
    IPAddress ip = IPAddress.Parse("127.0.0.1");
    IPEndPoint point = new IPEndPoint(ip, 5000);
    serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    serverSocket.Bind(point);
    serverSocket.Listen(5);
    txtRecv.Text = "开始侦听...";
    Thread tListen = new Thread(Listening);
    tListen.IsBackground = true;
    tListen.Start();
}

private void btnSend_Click(object sender, EventArgs e)
{
    if (txtSend.Text != "")
    {
        byte[] sendBuff = Encoding.Unicode.GetBytes(txtSend.Text);
        subSocket.Send(sendBuff, sendBuff.Length, SocketFlags.None);
        txtSend.Clear(); //清除发送文本框
    }
}

private void txtSend_KeyDown(object sender, KeyEventArgs e)
{   //监控文本框键盘事件,如果按下回车键,则发送消息
    if (e.KeyCode == Keys.Enter)
    {
        btnSend_Click(null, null);
    }
}

private void Listening()
{
    subSocket = serverSocket.Accept();

    this.Invoke(new Action(() =>
    {
        btnSend.Enabled = true;
        txtRecv.Text += "\r\n获取来自" + subSocket.RemoteEndPoint.ToString() + "的连接";
    }));
    byte[] buff = new byte[1024];
    try
    {
        while (true)
        {
            int count = subSocket.Receive(buff, buff.Length, SocketFlags.None);
            string recvStr = Encoding.Unicode.GetString(buff, 0, count);
            this.Invoke(new Action(() => txtRecv.Text += "\r\n" + recvStr));
        }
    }
    catch (Exception e)
    {
        this.Invoke(new Action(() => txtRecv.Text += "\r\n" + e.Message));
    }
    finally
    {
        subSocket.Close();
    }
}

由于只接受一个客户端连接,这里只开了一个线程用于监听连接和等待接收消息。其实发送也应该做成异步的,因为当发送出问题时也会阻塞程序,只是出问题的几率不大,所以这里省事直接使用同步代码。

客户端程序

界面如下图所示:

下表是各控件属性设置:

控件类型 控件命名 需设置的属性
TextBox txtIP text:127.0.0.1
TextBox txtPort text:5000
Button btnConnect
TextBox txtRecv Multiline:True
ScrollBars:Vertial
TextBox txtSend Text:连接服务器
Button btnSend Text:发送

代码如下:

Socket clientSocket;
private void btnConnect_Click(object sender, EventArgs e)
{
    IPAddress ip = IPAddress.Parse(txtIP.Text);
    int port = int.Parse(txtPort.Text);
    clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    clientSocket.Connect(ip, port);
    try
    {
        if (clientSocket.Connected)
        {
            txtRecv.Text = "连接服务器 " + txtIP.Text + " 成功";
            btnConnect.Enabled = false;
            btnSend.Enabled = true;
        }
        Thread tRecv = new Thread(ReceiveMessage);
        tRecv.IsBackground = true;
        tRecv.Start();
    }
    catch (Exception ex)
    {
        txtRecv.Text += "\r\n" + ex.Message;
    }
}

private void btnSend_Click(object sender, EventArgs e)
{
    if (txtSend.Text != "")
    {
        byte[] sendBuff = Encoding.Unicode.GetBytes(txtSend.Text);
        clientSocket.Send(sendBuff, sendBuff.Length, SocketFlags.None);
        txtSend.Clear(); //清除发送文本框
    }
}

private void txtSend_KeyDown(object sender, KeyEventArgs e)
{
    //监控文本框键盘事件,如果按下回车键,则发送消息
    if (e.KeyCode == Keys.Enter)
    {
        btnSend_Click(null, null);
    }
}

private void ReceiveMessage()
{
    byte[] buff = new byte[1024];
    try
    {
        while (true)
        {
            int count = clientSocket.Receive(buff, buff.Length, SocketFlags.None);
            string recvStr = Encoding.Unicode.GetString(buff, 0, count);
            this.Invoke(new Action(() => txtRecv.Text += "\r\n" + recvStr));
        }
    }
    catch (Exception e)
    {
        this.Invoke(new Action(() =>
        {
            txtRecv.Text += "\r\n" + e.Message;
            btnConnect.Enabled = true;
            btnSend.Enabled = false;
        }));
    }
    finally
    {
        clientSocket.Close();
    }
}

代码和服务端类似,只是用了一个接收消息线程,发送消息同步。

运行效果如下图所示: