网络编程套接字---tcp简单通信程序详解


本篇博客的目的是用socket套接字及接口实现一个简单的tcp聊天程序。

实现步骤

服务端:

  1. 创建套接字listen_sock
  2. 绑定地址信息(IP地址,端口号)
  3. 监听套接字listen_sock
  4. 与服务端建立链接
  5. 接收客户端发来的数据
  6. 向客户端发送数据
  7. 关闭套接字

客户端:
8. 创建套接字sockfd
9. 绑定地址信息(内部完成,不手动绑定)
10. 申请与客户端建立连接
11. 建立连接成功
12. 向服务端发送数据
13. 接收服务端发来的数据
14. 关闭套接字

服务端:

1.创建套接字socket


  //1.创建监听套接字listen_sock,参数依次为版本IPv4,流式套接字,默认tcp
  int listen_sock = socket(AF_INET,SOCK_STREAM,0);
  if (listen_sock < 0)
  {
    perror("create socket failed!");
    return -1;
  }

2.绑定地址信息

 //2.为服务器绑定地址信息,以后凡是向服务器发起连接请求,都会首先让listen_sock处理。

  struct sockaddr_in server_addr; //sockaddr_in仅用于IPv4
  socklen_t size = sizeof(server_addr);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[2]));//先将命令行第二个参数(端口号)转为整形再转为网络字节序。
  server_addr.sin_addr.s_addr = inet_addr(argv[1]);//将命令行第一个参数(ip地址)转为网络字节序。
   int ret = bind(listen_sock,(struct sockaddr*)&server_addr,size);

这里创建一个地址信息块,存储了服务端的IP地址和端口号,在后面的bind函数中传入作为第二个参数,bind成功,就等同于向操作系统说明,只要是向这个IP地址和端口发送的数据,都先经过我listen_sock这里。

这里同时也要注意网络字节序的转换,我们先来看一看sockaddr_in的结构:

typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;


struct in_addr
 {                                                                                                                                 
      in_addr_t s_addr;            
 };     

struct sockaddr_in                                                                                                                   
{ 
		   __SOCKADDR_COMMON (sin_);                                                                                                                                    
 		  in_port_t sin_port;     /* Port number.  */
		  struct in_addr sin_addr;    /* Internet address.  */                                                 
 }; 

可以看出,port是无符号16位的整型,sin_addr则是4个字节的整型,
而在网络字节序的函数,我们应该选择的就是htons(host to net small )和inet_addr。

  #include <arpa/inet.h>

       uint32_t htonl(uint32_t hostlong);(对无符号long4个字节转换为网络字节序的顺序)

       uint16_t htons(uint16_t hostshort);(对无符号short2个字节转换为网络字节序的数据)

       uint32_t ntohl(uint32_t netlong);(将4个字节的网络字节序数据转换为当前的主机字节序数据)

       uint16_t ntohs(uint16_t netshort);将一个16位(2个字节)由网络字节序转变为主机字节序

    	in_addr_t inet_addr(const char *cp);:将一个十进制的字符串转换为互联网标准转换成,
    
      int inet_pton(int af, const char *src, void *dst);//效果同inet_ntoa

3.开始监听socket

 //3.监听listen_sock套接字
  if (listen(listen_sock,5))
  {
    perror("listen error!");
    return -1;
  }

一旦开始监听,操作系统就会分配一块儿缓冲区作为连接成功队列,listen的第二个参数决定这这个队列的大小,也叫最大并发连接数
这个队列非常重要,当一个链接到来时,listen_socket检查队列是否还有位置,有就将任务置入队列,创建一个新的socket与该客户端进行通信。

为什么要建立一个新的newsocket,而不直接用监听socket与客户端通信?
原因其实很简单,我们举个例子,我们去中介哪儿租房,中间见到我们,说,行,现在里屋还有位置,你进去里屋,我安排一个想出租房的人和你聊吧。在这里,我们对应着客户,中介对应着监听socket,里屋对应着链接成功队列,出租房子的人对应newsocket。如果我们和socket直接通信,那就等于和中介谈租房的事情,显然不合适。所以在程序中,监听套接字只处理和我们的建立连接的事务,而不亲自和我们通信,一旦链接建立成功,他就会创建一个新的套接字,也就是newsocket和我们通信。每个不同的客户对应不同的newsocket,大家互不打扰,这也侧面说明每条tcp通信都是1对1的。

为什么每个链接的目的ip和目的端口号都是相同的,服务器还是能每个都处理好呢?
答:注意!我们说的和客户端建立链接用的socket是监听socket,他只负责将这个任务注册进任务队列,并不去确定客户的原ip和原端口,而当这个连接请求进入任务队列后,监听socket会创建一个新的socket(newsocket)来与客户端进行数据交互,同时在任务队列外再创建一块缓冲区,在这个新的缓冲区内,newsocket才会确定客户端的原ip和原端口。每个链接的原ip和原端口是不同的,所以操作系统能够区分清楚各个链接,从而达到与多个客户端通信。

4.建立连接成功

	 struct sockaddr_in client_addr;
    socklen_t size = sizeof(client_addr);
    int newsock = accept(listen_sock,(struct sockaddr*)&client_addr,&size);
    //阻塞等待式的接收新链接,client_addr接收客户端的地址信息
    if (newsock < 0)
    {
      perror("accept error!");
      //注意,此处说明该链接无效,但不影响别的链接,应该重新回到上面再次接收新链接
      continue;
    }
    //接收成功
    std::cout<<"get a new connection!"<<std::endl;

建立链接所用的accept函数,是一个阻塞式的函数,服务端创建好链接成功队列后,就开始阻塞式的等待客户端的链接,当有客户端连进来,accept立即捕获这个链接,并返回一个套接字描述符,也就是我们前面说的newsocket。
倘若accept出现错误,不能直接return,要注意!服务器不会因为和一个客户端建立链接失败就直接关闭,而是应该重新等待新的链接,故此处用continue。

5-6 与客户端进行数据通信

 while(1)
  {
  
    while(1)
    {
      //5.收发数据
      char buff[1024] = {0};
      int len = recv(newsock , buff , 1024 , 0);
      if (len < 0)//recv返回值小于0时,接收数据错误
      {
        perror("recv error!");
        close(newsock);
        continue;
      }
      else if (len == 0)//recv返回值等于0时,说明对端(客户端)关闭
      {
        perror("the opposite process is stopped!");
        close(newsock);
        continue;
      }
      //运行到此处说明接收成功,接受的数据存放在buff中

      printf("client[ %s %d] say: %s\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),buff);
      //注意此处对数据要从网络字节序转化为主机字节序

      memset(buff,0x00,1024);
      std::cin >> buff; 
      send(newsock,buff,strlen(buff),0);

    }
  }

这里要注意recv和send函数,udp通信使用的是recvfrom和sendto,我有一篇博客写他们之间的区别,可以参考一下:从tcp,udp链接角度看send和sendto的区别

如何判断tcp断开链接?

recv:正常接收,返回实际接收长度。 接受失败 返回 -1 对端断开 0
send:正常发送,返回值为所发送数据的总数。
如果对端断开,但发送的报文会导致对端发送RST报文, 因为对端的socket已经调用了close, 完全关闭, 既不发送, 也不接收数据. 所以, 第二次调用send(假设在收到RST之后), 会生成SIGPIPE信号, 导致进程退出 。

所以我们在recv下面添加了判断语句,如果recv返回值为0,那么就close掉当前的newsocket,返回上文继续等待新的客户端。

7关闭socket

close(listen_sock);

服务器停掉前,肯定要关闭监听套接字了。

客户端与服务端逻辑类似,且代码中有加入注释,就不多赘述了。下面放代码

tcp通信程序1.0版本(不能支持多个客户端通信)

tcp_server.cpp

//目标:创建一个基于tcp的简单聊天程序
#include<iostream>
#include<unistd.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
//思路:
//服务端:
//1.创建套接字listen_socket
//2.为套接字绑定服务器地址
//3.开始监听listen_socket
//4.接收链接成功,使用new_socket
//5.与new_socket收发数据
//6.关闭socket

//    /usr/include/netinet/in.h
int main(int argc,char* argv[])
{
  if (argc != 3)//判断参数是否足够
  {
    perror("you should put more paramaters!");
    return -1;
  }

  //1.创建监听套接字listen_sock,参数依次为版本IPv4,流式套接字,默认tcp
  int listen_sock = socket(AF_INET,SOCK_STREAM,0);
  if (listen_sock < 0)
  {
    perror("create socket failed!");
    return -1;
  }

  //2.为服务器绑定地址信息,以后凡是向服务器发起连接请求,都会首先让listen_sock处理。

  struct sockaddr_in server_addr; //sockaddr_in仅用于IPv4
  socklen_t size = sizeof(server_addr);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[2]));//先将命令行第二个参数(端口号)转为整形再转为网络字节序。
  server_addr.sin_addr.s_addr = inet_addr(argv[1]);//将命令行第一个参数(ip地址)转为网络字节序。

  int ret = bind(listen_sock,(struct sockaddr*)&server_addr,size);
  if (ret < 0)
  {
    perror("bind error!");
    return -1;
  }

  //3.监听listen_sock套接字
  if (listen(listen_sock,5))
  {
    perror("listen error!");
    return -1;
  }

  while(1)
  {
    //4.接收新链接
    struct sockaddr_in client_addr;
    socklen_t size = sizeof(client_addr);
    int newsock = accept(listen_sock,(struct sockaddr*)&client_addr,&size);//阻塞等待式的接收新链接,client_addr接收客户端的地址信息
    if (newsock < 0)
    {
      perror("accept error!");//注意,此处说明该链接无效,但不影响别的链接,应该重新回到上面再次接收新链接
      continue;
    }
    //接收成功
    std::cout<<"get a new connection!"<<std::endl;
    //开始用newsock和客户端进行数据通信
    while(1)
    {
      //5.收发数据
      char buff[1024] = {0};

      int len = recv(newsock , buff , 1024 , 0);
      if (len < 0)//recv返回值小于0时,接收数据错误
      {
        perror("recv error!");
        close(newsock);
        continue;
      }
      else if (len == 0)//recv返回值等于0时,说明对端(客户端)关闭
      {
        perror("the opposite process is stopped!");
        close(newsock);
        continue;
      }
      //运行到此处说明接收成功,接受的数据存放在buff中

      printf("client[ %s %d] say: %s\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),buff);
      //注意此处对数据要从网络字节序转化为主机字节序

      memset(buff,0x00,1024);
      std::cin >> buff; 
      send(newsock,buff,strlen(buff),0);

    }
  }
  close(listen_sock);
  return 0;
}

tcp_client.cpp

#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<string.h>
#include<stdlib.h>
#include<stdio.h>

//目标:创建一个简单的tcp聊天客户端
//
//1.创建套接字socket
//2.绑定地址信息(无需绑定,绑定可能出错,且我们在服务端可以用recv函数接收到客户端的地址信息)
//3.与服务端建立连接(connect)
//4.向服务端发送数据
//5.接收服务端发来的数据
//6.不聊了关闭套接字

//
//如何判断TCP连接断开?(重点)
//对于接收方来说----recv返回值为0,代表连接断开
//对于发送方来说----每次调用send都会触发连接断开异常,接收到系统发送的SIGPIPE信号,导致进程退出.
//如果不想要发送方退出,我们要在程序开始的时候对SIGPIPE做自定义/忽略处理

int main(int argc , char* argv[])
{
    if (argc != 3)//检查是否缺少参数
    {
        perror("you should put more paramaters!");
        return -1;
    }
    //1创建套接字sockfd
    int sockfd = socket(AF_INET,SOCK_STREAM,0);
    if (sockfd < 0)
    {
        perror("create socket error!");
        return -1;
    }

    //2不绑定客户端的地址信息
    //
    //创建一个地址信息块,存储服务端的地址信息,后面connect,send用得上
    struct sockaddr_in server_addr;//仅用于IPv4
    socklen_t len = sizeof(server_addr);
    server_addr.sin_family = AF_INET;//类型
    server_addr.sin_port = htons(atoi(argv[2]));//端口是无符号short型,这里要先将参数转为int,再对低2个字节进行网络字节序的转换。
    server_addr.sin_addr.s_addr = inet_addr(argv[1]);//inet_addr可以完成字符串的网络字节序转换。

    //3.申请与服务端建立连接。
   int ret =  connect(sockfd, (struct sockaddr *)&server_addr, len);
   if (ret < 0)
   {
        perror("connect error!");
        close(sockfd);
        return -1;
   }

   //至此,链接链接建立成功
   
   while (1)
   {
        //4.向服务端发送数据
        
        char buff[1024] = {0};
        scanf("%s",buff);
        send(sockfd,buff,strlen(buff),0);

        //5.从服务端接收数据
        memset(buff,0x00,1024);
        int ret = recv(sockfd,buff,1024,0);
        if (ret < 0)
        {
            //客户端接收数据失败
            perror("recv error!");
            close(sockfd);
            return -1;
        }
        else if (ret ==0)
        {
            //对端已经关闭
            perror("the tcp_server is closed!");
            close(sockfd);
            return -1;
        }
        //输出buff的内容,要注意ip和端口从网络字节序到主机字节序的转换
        printf("server[%s %d]say: %s\n",inet_ntoa(server_addr.sin_addr),ntohs(server_addr.sin_port),buff);


   }
    
    close(sockfd);
    return 0;
}

程序的问题

这个程序只能与一个客户端维持正常通信,一旦打开一个新的客户端,能连接但服务端接收不到信息。这是为什么呢?
网络编程套接字---tcp简单通信程序详解

外层while循环是不断的等待新的客户端连接的到来,但只要有1个客户端进来,就会进入内层循环一直通信,导致外层的accept无法执行,所以客户端根本就不知道有新的链接到来。

解决思路:

1.将服务端改成多进程版本:

可以每当一个连接到来时,就fork()一个子进程,让子进程去完成通信任务,父进程仍然在处理建立链接的任务。我们又知道,当子进程退出,会给父进程发送SIGCHID信号,我们只需要用signal()函数重定义SIGCHID的行为,让他wait一下就可以了。

修改后的server

//目标:创建一个基于tcp的简单聊天程序
#include<iostream>
#include<unistd.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<signal.h>
#include<sys/wait.h>
//思路:
//服务端:
//1.创建套接字listen_socket
//2.为套接字绑定服务器地址
//3.开始监听listen_socket
//4.接收链接成功,使用new_socket
//5.与new_socket收发数据
//6.关闭socket

int Communication(int newsock)
{
  int pid = fork();
  if (pid < 0)
  {
    perror("fork error!");
    return -1;
  }
  else if (pid == 0)
  {

    //开始用newsock和客户端进行数据通信
    while(1)
    {
      //5.收发数据
      char buff[1024] = {0};

      int len = recv(newsock , buff , 1024 , 0);
      if (len ==  0)//recv返回值小于等于0时,释放套接字后退出
      {
        std::cout<<"the opposite is closed"<<std::endl;
        close(newsock);
        exit(-1);
      }
      else if (len < 0)
      {
        std::cout<<"recv error"<<std::endl;
        close(newsock);
        exit(-1);
      }
      //运行到此处说明接收成功,接受的数据存放在buff中

      printf("client say: %s\n",buff);
      //注意此处对数据要从网络字节序转化为主机字节序
      
      send(newsock,"hello~",6,0);
    }

  }
    close(newsock);
    return 0;
}



void sigcb(int signo)
{
  while (waitpid(-1,NULL,WNOHANG) != 0)
  {}

}
int main(int argc,char* argv[])
{
  signal(SIGCHLD,sigcb);
  if (argc != 3)
  {
    perror("you should put more paramaters!");
    return -1;
  }

  //1.创建监听套接字listen_sock,参数依次为版本IPv4,流式套接字,默认tcp
  int listen_sock = socket(AF_INET,SOCK_STREAM,0);
  if (listen_sock < 0)
  {
    perror("create socket failed!");
    return -1;
  }

  //2.为服务器绑定地址信息,以后凡是向服务器发起连接请求,都会首先让listen_sock处理。

  struct sockaddr_in server_addr; //sockaddr_in仅用于IPv4
  socklen_t size = sizeof(server_addr);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[2]));//先将命令行第二个参数(端口号)转为整形再转为网络字节序。
  server_addr.sin_addr.s_addr = inet_addr(argv[1]);//将命令行第一个参数(ip地址)转为网络字节序。

  int ret = bind(listen_sock,(struct sockaddr*)&server_addr,size);
  if (ret < 0)
  {
    perror("bind error!");
    return -1;
  }

  //3.监听listen_sock套接字
  if (listen(listen_sock,5))
  {
    perror("listen error!");
    return -1;
  }

  while(1)
  {
    //4.接收新链接
    struct sockaddr_in client_addr;
    socklen_t size = sizeof(client_addr);
    int newsock = accept(listen_sock,(struct sockaddr*)&client_addr,&size);//阻塞等待式的接收新链接,client_addr接收客户端的地址信息
    if (newsock < 0)
    {
      perror("accept error!");//注意,此处说明该链接无效,但不影响别的链接,应该重新回到上面再次接收新链接
      continue;
    }
    //接收成功
    std::cout<<"get a new connection!"<<std::endl;
    Communication(newsock);
  }
  close(listen_sock);
  return 0;
}


2.多线程版本

和进程一样,没来一个链接都创建一个线程来执行这个通信任务,将线程分离后也不用担心资源是否回收,其他较多进程版本其实没有太大变化。

//目标:创建一个基于tcp的简单聊天程序
#include<iostream>
#include<unistd.h>
#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<signal.h>
#include<sys/wait.h>
#include<pthread.h>
//思路:
//服务端:
//1.创建套接字listen_socket
//2.为套接字绑定服务器地址
//3.开始监听listen_socket
//4.接收链接成功,使用new_socket
//5.与new_socket收发数据
//6.关闭socket

int Communication(void* arg)
{
    pthread_detach(pthread_self());
    int newsock = (int)arg;
    while(1)
    {
      //5.收发数据
      char buff[1024] = {0};

      int len = recv(newsock , buff , 1024 , 0);
      if (len ==  0)//recv返回值小于等于0时,释放套接字后退出
      {
        std::cout<<"the opposite is closed"<<std::endl;
        close(newsock);
        return 0;
      }
      else if (len < 0)
      {
        std::cout<<"recv error"<<std::endl;
        close(newsock);
        return -1;
      }
      //运行到此处说明接收成功,接受的数据存放在buff中

      printf("client say: %s\n",buff);
      //注意此处对数据要从网络字节序转化为主机字节序
      
      send(newsock,"hello~",6,0);
    }
    close(newsock);
    return NULL;
}





int main(int argc,char* argv[])
{
  if (argc != 3)
  {
    perror("you should put more paramaters!");
    return -1;
  }

  //1.创建监听套接字listen_sock,参数依次为版本IPv4,流式套接字,默认tcp
  int listen_sock = socket(AF_INET,SOCK_STREAM,0);
  if (listen_sock < 0)
  {
    perror("create socket failed!");
    return -1;
  }

  //2.为服务器绑定地址信息,以后凡是向服务器发起连接请求,都会首先让listen_sock处理。

  struct sockaddr_in server_addr; //sockaddr_in仅用于IPv4
  socklen_t size = sizeof(server_addr);
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(atoi(argv[2]));//先将命令行第二个参数(端口号)转为整形再转为网络字节序。
  server_addr.sin_addr.s_addr = inet_addr(argv[1]);//将命令行第一个参数(ip地址)转为网络字节序。

  int ret = bind(listen_sock,(struct sockaddr*)&server_addr,size);
  if (ret < 0)
  {
    perror("bind error!");
    return -1;
  }

  //3.监听listen_sock套接字
  if (listen(listen_sock,5))
  {
    perror("listen error!");
    return -1;
  }

  while(1)
  {
    //4.接收新链接
    struct sockaddr_in client_addr;
    socklen_t size = sizeof(client_addr);
    int newsock = accept(listen_sock,(struct sockaddr*)&client_addr,&size);//阻塞等待式的接收新链接,client_addr接收客户端的地址信息
    if (newsock < 0)
    {
      perror("accept error!");//注意,此处说明该链接无效,但不影响别的链接,应该重新回到上面再次接收新链接
      continue;
    }
    //接收成功
    std::cout<<"get a new connection!"<<std::endl;
    pthread_t tid;
    pthread_create(&tid,NULL,Communication,(void*)newsock);
  }
  close(listen_sock);
  return 0;
}


两个版本的比较

多线程版本固然有好处:资源占用少,创建/销毁较多进程版本要小,但是也有不好的地方,倘若一个线程因异常崩溃,整个进程都受到影响,服务器就被搞挂了,所以没有绝对的好与坏,的看需求和应用场景再做决断。