网络 IO 之 tcp 服务端

网络 I/O

网络是后端开发的重要环节,各种使用场景的底层网络是怎么做的,如:

  • 微信聊天时,语音、文字、视频等发送与网络 I/O 有什么关系
  • 刷短视频时,视频是如何呈现在你的 app 上的
  • github/gitlab, git clone, 为什么能够到达本地
  • 使用共享设备时,扫描以后,设备是如何开锁的
  • 家里的电子设备是如何通过手机 app 进行操作

上述的场景都是用网络解决问题,这些场景一般都会有一个服务器端,然后有客户端去连接进行网络通信。服务器端和客户端之间会建立连接,这个连接相当于水管,用来管理两个之间的数据通信。通过这个连接渠道发送什么内容不是我们关注的,我关注的是如何建立这个连接以及如何发送和接收数据。

我们通过一个简单的服务器代码来理解:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>

int main() {
  // 1. 创建 socket ——> 在 Linux 中创建 socket 只能使用这一种方式
  int servfd = socket(AF_INET, SOCK_STREAM, 0);
  if (-1 == servfd) {
    perror("socket error");
    exit(EXIT_FAILURE);
  }

  // 2. 绑定网络地址信息
  struct sockaddr_in serv_addr;
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // INADDR_ANY 表示 0.0.0.0,代表所有网段
  serv_addr.sin_port = htons(9090); // 0~1023 是系统默认的,端口号建议使用 1024 以后的端口号,端口一旦绑定就不能再次绑定
  if (-1 == bind(servfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
    perror("bind error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  // 3. 进入可连接状态
  if (-1 == listen(servfd, 10)) {
    perror("listen error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  getchar();
  close(servfd);

  return 0;
}

编译运行上面的程序后,使用 netstat -anop |grep 9090 查看指定端口的网络状态,如下图所示:

在这里插入图片描述

此时的服务器端是正常启动的状态,但是如果此时再将此程序以相同的 IP 和端口启动,则会出现错误。这是因为端口已被占用,一个 IP 的每个端口只能被绑定一次,就跟坐车一样,一个座位只能坐一个人。

在这里插入图片描述

添加了 listen 就意味着这个服务器端可以被连接,也就是说客户端可以连接到此服务端中。我们使用网络助手工具向服务器发起连接,此时的客户端以连接成功,同样我们使用 netstat 命令查看网络状态,此时多了一个 ESTABLISHED 状态的信息。此时客户端也可以与服务器端进行通信,但是我们无法显示数据,因为程序中没有添加接收数据的代码。

在这里插入图片描述

服务器端要向接收客户端的数据,还需要使用 fd 与客户端建立一对一的连接关系,此时需要调用 accpt 函数。为什么建立连接关系还要使用新的 fd —— 这就好比有一个酒店,使用 socket 函数招到一个迎宾小姐,使用 bind 函数将迎宾小姐安排到固定的位置进行迎宾,此时来了客人,就需要让迎宾小姐带到酒店内部,然后就会有一个新的服务员为这个客人提供点单等服务,而迎宾小姐继续去原来的位置等待下一个客人。这里新的服务员就相当与新的 fd,为客人单独提供服务就是建立一对一的关系。

下面通过简单的代码示例进行理解:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>

int main() {
  // 1. 创建 socket ——> 在 Linux 中创建 socket 只能使用这一种方式
  int servfd = socket(AF_INET, SOCK_STREAM, 0);
  if (-1 == servfd) {
    perror("socket error");
    exit(EXIT_FAILURE);
  }

  // 2. 绑定网络地址信息
  struct sockaddr_in serv_addr;
  memset(&serv_addr, 0, sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // INADDR_ANY 表示 0.0.0.0,代表所有网段
  serv_addr.sin_port = htons(9090); // 0~1023 是系统默认的,端口号建议使用 1024 以后的端口号,端口一旦绑定就不能再次绑定
  if (-1 == bind(servfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
    perror("bind error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  // 3. 进入可连接状态
  printf("before listen\n");
  if (-1 == listen(servfd, 10)) {
    perror("listen error");
    close(servfd);
    exit(EXIT_FAILURE);
  }
  printf("after listen\n");

  struct sockaddr_in clnt_addr;
  memset(&clnt_addr, 0, sizeof(clnt_addr));
  int addr_len = sizeof(clnt_addr);
  int clntfd = accept(servfd, (struct sockaddr *)&clnt_addr, &addr_len);
  if (-1 == clntfd) {
    perror("accept error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  char message[1024] = {0};
  int count = recv(clntfd, message, 1024, 0);
  printf("RECV: %s\n", message);
  count = send(clntfd, message, count, 0);
  printf("SEND: %d\n", count);

  getchar();
  close(servfd);

  return 0;
}

通过上面的程序可以实现客户端与服务器端的数据交互。

上述的代码均有一个问题,客户端可以连接多个,但是编写的服务器端程序只能处理一个客户端。在上面也提到过,每连接一个客户端就需要一个 fd 与之一一对应,那么在代码中该如何做到。这里使用一个简单的方式:使用一个循环,在循环中通过 accept 将 fd 与客户端的连接建立一对一的关系,代码示例如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>

int main() {
  // 1. 创建 socket ——> 在 Linux 中创建 socket 只能使用这一种方式
  int servfd = socket(AF_INET, SOCK_STREAM, 0);
  if (-1 == servfd) {
    perror("socket error");
    exit(EXIT_FAILURE);
  }

  // 2. 绑定网络地址信息
  struct sockaddr_in serv_addr;
  memset(&serv_addr, 0, sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // INADDR_ANY 表示 0.0.0.0,代表所有网段
  serv_addr.sin_port = htons(9090); // 0~1023 是系统默认的,端口号建议使用 1024 以后的端口号,端口一旦绑定就不能再次绑定
  if (-1 == bind(servfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
    perror("bind error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  // 3. 进入可连接状态
  printf("before listen\n");
  if (-1 == listen(servfd, 10)) {
    perror("listen error");
    close(servfd);
    exit(EXIT_FAILURE);
  }
  printf("after listen\n");

  while (1) {
    struct sockaddr_in clnt_addr;
    memset(&clnt_addr, 0, sizeof(clnt_addr));
    int addr_len = sizeof(clnt_addr);
    int clntfd = accept(servfd, (struct sockaddr *)&clnt_addr, &addr_len);
    if (-1 == clntfd) {
      perror("accept error");
      close(servfd);
      exit(EXIT_FAILURE);
    }

    char message[1024] = {0};
    int count = recv(clntfd, message, 1024, 0);
    printf("RECV: %s\n", message);
    count = send(clntfd, message, count, 0);
    printf("SEND: %d\n", count);
  }

  getchar();
  close(servfd);

  return 0;
}

现在这个服务器端的程序可以处理多个客户端的连接,但是这个程序还是存在缺陷:这个服务器端只能按序处理连接上的客户端,也就是说在同一时刻只能处理一个客户端的数据。如果不是按序的处理客户端,程序可能会阻塞住,可以查看下面的流程图理解阻塞的地方。

Yes
下一个客户端
socket
bind
accept
recv
printf
recturn

通过上述的流程图,如果不按序处理客户端的数据,当程序启动后,此时程序就到了流程中的 accept 这里,等待客户端的连接。一旦第一个客户端连接成功后,程序就到了流程中的 recv 这里。然而这个流程还没有结束,我们又连接了其他的客户端,并且使用其他客户端进行发送数据,服务器端当然收不到数据。因为第一个客户端的数据处理流程还没有跑完,其他的客户端的数据处理流程怎么可能执行。如果我们希望每个客户端能够独立发送数据,就需要用到线程,一旦一个客户端连接后就立即创建一个线程进行管理,这样就不会因为代码逻辑而阻塞。代码示例如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <pthread.h>

void * client_thread(void *arg);

int main() {
  // 1. 创建 socket ——> 在 Linux 中创建 socket 只能使用这一种方式
  int servfd = socket(AF_INET, SOCK_STREAM, 0);
  if (-1 == servfd) {
    perror("socket error");
    exit(EXIT_FAILURE);
  }

  // 2. 绑定网络地址信息
  struct sockaddr_in serv_addr;
  memset(&serv_addr, 0, sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // INADDR_ANY 表示 0.0.0.0,代表所有网段
  serv_addr.sin_port = htons(9090); // 0~1023 是系统默认的,端口号建议使用 1024 以后的端口号,端口一旦绑定就不能再次绑定
  if (-1 == bind(servfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
    perror("bind error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  // 3. 进入可连接状态
  printf("before listen\n");
  if (-1 == listen(servfd, 10)) {
    perror("listen error");
    close(servfd);
    exit(EXIT_FAILURE);
  }
  printf("after listen\n");

  while (1) {
    struct sockaddr_in clnt_addr;
    memset(&clnt_addr, 0, sizeof(clnt_addr));
    int addr_len = sizeof(clnt_addr);
    int clntfd = accept(servfd, (struct sockaddr *)&clnt_addr, &addr_len);
    if (-1 == clntfd) {
      perror("accept error");
      close(servfd);
      exit(EXIT_FAILURE);
    }

    pthread_t cthread;
    pthread_create(&cthread, NULL, client_thread, &clntfd);
  }

  getchar();
  close(servfd);

  return 0;
}

void* client_thread(void *arg) {
  int clntfd = *(int *)arg;

  char message[1024] = {0};
  int count = recv(clntfd, message, 1024, 0);
  printf("RECV: %s\n", message);
  count = send(clntfd, message, count, 0);
  printf("SEND: %d\n", count);
}

现在可以实现多个客户端独立发送消息,但是上述的程序在处理客户端的数据时,发现只能处理一次客户端发来的消息,这里主要是代码中的逻辑只是进行了一次处理的操作,只需在接收消息的地方增加一个循环即可实现多次接收,修改的代码如下:

void* client_thread(void *arg) {
  int clntfd = *(int *)arg;

  while (1) {
    char message[1024] = {0};
    int count = recv(clntfd, message, 1024, 0);
    printf("RECV: %s\n", message);
    count = send(clntfd, message, count, 0);
    printf("SEND: %d\n", count);
  }
}

至此,一个可以实现多客户端独立发送数据的服务端程序已完成,这种服务端程序的模型是一请求一线程的方式,但是这种方式存在一些缺点:

  • 当并发数较大的时候,需要创建大量线程来处理连接,系统资源占用较大
  • 连接建立后,如果当前线程暂时没有数据可读,则该线程则会阻塞在 recv 操作上,造成线程浪费
    在这里插入图片描述

在最后还发现一个小问题,上面的程序,当客户端断开连接后,服务器端会一直发送空的数据,并且在某一时刻会出现 cpu 使用率 100% 的情况。主要原因是服务器端没有对客户端断开连接的进行处理,一旦客户端断开连接,服务器端接收到的数据大小就为 0,通过这个返回值可以进行处理,代码示例如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <pthread.h>

void * client_thread(void *arg);

int main() {
  // 1. 创建 socket ——> 在 Linux 中创建 socket 只能使用这一种方式
  int servfd = socket(AF_INET, SOCK_STREAM, 0);
  if (-1 == servfd) {
    perror("socket error");
    exit(EXIT_FAILURE);
  }

  // 2. 绑定网络地址信息
  struct sockaddr_in serv_addr;
  memset(&serv_addr, 0, sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // INADDR_ANY 表示 0.0.0.0,代表所有网段
  serv_addr.sin_port = htons(9090); // 0~1023 是系统默认的,端口号建议使用 1024 以后的端口号,端口一旦绑定就不能再次绑定
  if (-1 == bind(servfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
    perror("bind error");
    close(servfd);
    exit(EXIT_FAILURE);
  }

  // 3. 进入可连接状态
  printf("before listen\n");
  if (-1 == listen(servfd, 10)) {
    perror("listen error");
    close(servfd);
    exit(EXIT_FAILURE);
  }
  printf("after listen\n");

  while (1) {
    struct sockaddr_in clnt_addr;
    memset(&clnt_addr, 0, sizeof(clnt_addr));
    int addr_len = sizeof(clnt_addr);
    int clntfd = accept(servfd, (struct sockaddr *)&clnt_addr, &addr_len);
    if (-1 == clntfd) {
      perror("accept error");
      close(servfd);
      exit(EXIT_FAILURE);
    }

    pthread_t cthread;
    pthread_create(&cthread, NULL, client_thread, &clntfd);
  }

  getchar();
  close(servfd);

  return 0;
}

void* client_thread(void *arg) {
  int clntfd = *(int *)arg;

  while (1) {
    char message[1024] = {0};
    int count = recv(clntfd, message, 1024, 0);
    if (0 == count) {
      printf("client disconnect: %d\n", clntfd);
      close(clntfd);
      break;
    }
    printf("RECV: %s\n", message);
    count = send(clntfd, message, count, 0);
    printf("SEND: %d\n", count);
  }
}

更多的总结内容可以阅读本人的博客网站:xjjeffery 的个人网站

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xjjeffery

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值