一切皆有可能
anywill ,anything will ....go better or worse ,go success or failure,go......一切皆有可能

网络编程之开发一个简单并发服务器

anywill~2020-01-03 /Linux编程

1·前言

1.1 什么是Socket?

Socket 起源于 UNIX,在 UNIX 一切皆文件的思想下,进程间通信就被冠名为文件描述符(file descriptor),Socket 是一种“打开—读/写—关闭”模式的实现,服务器和客户端各自维护一个“文件”,在建立连接打开后,可以向文件写入内容供对方读取或者读取对方内容,通讯结束时关闭文件。

Socket抽象层在网络分层中的位置:

1.2 TCP为什么比UDP可靠?

TCP/IP(Transmission Control Protocol/Internet Protocol)即传输控制协议/网间协议,是一种可靠传输协议。

UDP(User Data Protocol,用户数据报协议)是与TCP相对应的协议。它是属于TCP/IP协议族中的一种,是一种非可靠传输协议。

TCP比UDP可靠地方如下:

第一、更加安全,因为TCP用的是3次握手,这样可以确保来源IP不会被伪造,防止反射性攻击,案例:Memcache,NTP等UDP反射性攻击

可靠的TCP 3次握手

UDP反射性攻击

说到TCP3次握手,这里再引入一个概念:backlog

syns queue

用于保存半连接状态的请求,其大小通过/proc/sys/net/ipv4/tcp_max_syn_backlog指定,一般默认值是512,不过这个设置有效的前提是系统的syncookies功能被禁用。互联网常见的TCP SYN FLOOD恶意DOS攻击方式就是建立大量的半连接状态的请求,然后丢弃,导致syns queue不能保存其它正常的请求。

accept queue

用于保存全连接状态的请求,其大小通过/proc/sys/net/core/somaxconn指定,在使用listen函数时,内核会根据传入的backlog参数与系统参数somaxconn,取二者的较小值。
如果accpet queue队列满了,server将发送一个ECONNREFUSED错误信息Connection refused到client。
所以,平时访问量大的服务器,要注意调节这个值。

第二、TCP传输过程提供更完善的,更可靠的机制

  1. 分块发送:应用数据被分割成TCP认为最适合发送的数据块。由TCP传递给IP的信息单位称为报文段或段(segment)
  2. 定时确认重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。 当TCP收到发自TCP连接另一端的数据,它将发送一个确认。这个确认不是立即发送,通常将推迟几分之一秒
  3. 数据校验:TCP将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP将丢弃这个报文段和不确认收到此报文段(希望发端超时并重发)。
  4. 正确排序:由于IP数据报的到达可能会失序,因此TCP报文段的到达也可能会失序。如果必要,TCP将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层。
  5. 重复丢弃:IP数据报会发生重复,TCP的接收端必须丢弃重复的数据。
  6. 流量控制:TCP连接的每一方都有固定大小的缓冲空间。TCP的接收端只允许另一端发送接收端缓冲区所能接纳的数据。这将防止较快主机致使较慢主机的缓冲区溢出。

2·Socket网络编程

2.1 一个简单的TCP反射客户程序

“反射”程序:客户端输入什么内容,服务端就返回什么内容。

一般客户端和服务端的交互流程

2.1.1 服务端tcpserv01.c

#include <stdio.h
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h> // for open
#include <unistd.h> // for close

//最大连接数
#define LISTENQ 1024

//服务器端口号
#define SERV_PORT 9877

//接收和发送的缓冲区大小
#define BUFSIZE 4096

//处理客户端请求函数
void str_echo(int confd);

int main(int argc, char **argv)
{
        int confd, listenfd;
        struct sockaddr_in cliaddr, servaddr;
        socklen_t clilen;
        int status;
        char buff[BUFSIZE];

        //设置协议地址结构内容
        bzero(&servaddr, sizeof(servaddr)); //清空内存内容
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port = htons(SERV_PORT);

        listenfd = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
        bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));//将协议、IP地址、端口绑定到套接字
        listen(listenfd, LISTENQ);//使套接字变为监听套接字
        while (1)
        {
            clilen = sizeof(cliaddr);//这一步最容易忘记
            confd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);//等待连接完成
            write(confd,buff,strlen(buff));
            str_echo(confd);
            close(confd);
        }
}

void str_echo(int confd)
{
    ssize_t     n;
    char        buf[BUFSIZE];

again:
    while ( (n = read(confd, buf, BUFSIZE)) > 0)
        write(confd, buf, n);

    if (n < 0)
        goto again;
    else if (n < 0)
        err_exit("str_echo: read error");
}

2.1.2 客服端tcpcli01.c

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <stdlib.h>
#include <arpa/inet.h>   /* inet(3) functions */
#include <fcntl.h> // for open
#include <unistd.h> // for close

#define SERV_PORT 9877

#define BUFSIZE 4096

//客户端具体操作函数
void str_cli(FILE *fp, int sockfd);

int main(int argc, char **argv)
{
        if (argc != 2)
        {
                printf("argument error\n");
                exit(0);
        }
        int sockfd;
        struct sockaddr_in servaddr;
        int status;

        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_port = htons(SERV_PORT);
        inet_pton(AF_INET, argv[1], &servaddr.sin_addr);//将点分十进制IP地址转化为网络字节序的二进制地址
        sockfd = socket(AF_INET, SOCK_STREAM, 0);
        status = connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));//连接服务器
        str_cli(stdin, sockfd);
        exit(0);
}

void str_cli(FILE *fp, int sockfd)
{
        char sendline[BUFSIZE], recvline[BUFSIZE];
        int n;
        while (fgets(sendline, BUFSIZE, fp) != NULL) {

                write(sockfd, sendline, strlen(sendline));
                n = read(sockfd, recvline, BUFSIZE);
                recvline[n] = 0;        /* null terminate */
                fputs(recvline, stdout);
        }

}

2.2 接下来分析一下代码

2.2.1 sockaddr_in 结构体

struct sockaddr_in
{
    sa_family_t         sin_family;        //地址族(Address Family)
    uint16_t              sin_port;          //16位TCP/UDP端口号
    struct in_addr     sin_add;          //32位IP地址
    char                    sin_zero[8];     //不使用
};

struct in_addr
{
    In_addr_t            s_addr;            //32位IPv4地址
};

1.成员sin_family 地址族(Address Family)  含义
AF_INET     IPv4网络协议中使用的地址族
AF_INET6    IPv6网络协议中使用的地址族
AF_LOCAL    本地通信中采用的Unix协议的地址族

2.成员sin_port
该成员保存16位端口号,重点在于,他以网络字节序保存。

3.成员sin_addr
该成员保存32位IP地址信息,且也以网络字节序保存。

2.2.2 socket函数

int socket(int family, int type, int protocol);



2.2.3 connect函数

int connect(int sockfd,const struct sockaddr *servaddr,socklen_t addrlen);
//返回:若成功返回0,若出错则为-1

2.2.4 bind函数

int bind(int sockfd,const struct sockaddr *servaddr,socklen_t addrlen);
//返回:若成功则为0,若出错则为-1

如果一个TCP客户端和服务端未曾调用bind捆绑一个端口好,当调用connect或listen时,内核就要为相应的套接字选择一个临时端口。

2.2.5 listen函数

int listen(int sockfd,int backlog);
//返回:若成功则为0,出错为-1

backlog曾被规定为两个队列总和的最大值,可惜不同操作系统对这值的操作有些出入,如下图:

2.2.6 程序运行结果

先运行服务端,再运行客户端

gcc tcpsrv01.c -o tcpsrv01 && ./tcpsrv01
gcc tcpcli01.c -o tcpcli01 && ./tcpcli01 127.0.0.1
然后客户端输入和显示内容:
hello
hello
world
world

3. TCP并发服务器

3.1 多客户端同时访问

上面的例子,我们新建多一个客户端tcpcli02,并执行。然后,我们发现再客户端2怎么输入都是没反应,再看看链接状态。

我们可以看看 lsof -i :9877 从连接状态查看客户端连接状态

COMMAND    PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
tcpsrv01 24711  Mai    3u  IPv4 0x96e9685f107ffce7      0t0  TCP *:9877 (LISTEN)
tcpsrv01 24711  Mai    4u  IPv4 0x96e9685f0c7b1907      0t0  TCP localhost:9877->localhost:51106 (ESTABLISHED)
tcpcli01 24713  Mai    3u  IPv4 0x96e9685f0f9c3907      0t0  TCP localhost:51106->localhost:9877 (ESTABLISHED)
tcpcli02 24909  Mai    3u  IPv4 0x96e9685f107e1af7      0t0  TCP localhost:51203->localhost:9877 (ESTABLISHED)

tcpcli01和tcpsrv01有2个链接,一来一回,tcpcli02只有单向的链接,因为这时tcpsrv01返回了synAck给tcpcli02,tcpcli02把状态改为ESTABLISHED,但tcpcli02链接还在acceppt queue里面,还没被accept()调用。

这时,我们断开tcpcli01,然后发现tcpcli02又可以正常工作了,这时的链接状态

COMMAND    PID USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
tcpsrv01 24711  Mai    3u  IPv4 0x96e9685f107ffce7      0t0  TCP *:9877 (LISTEN)
tcpsrv01 24711  Mai    4u  IPv4 0x96e9685f104353ef      0t0  TCP localhost:9877->localhost:51248 (ESTABLISHED)
tcpcli02 25038  Mai    3u  IPv4 0x96e9685f0fb7caf7      0t0  TCP localhost:51248->localhost:9877 (ESTABLISHED)

当链接从被accept()调用,从accept queue出来,系统才会标记这个链接为ESTABLISHED。

实际中,我们不可能等第一个客户端结束了才处理第二个客户端,这样的,处理速度太慢,那我们怎么同时处理2个客户端呢?对了,就是用多进程处理。

3.2 一个多进程的TCP反射程序

多进程tcpsrv02.c

#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <arpa/inet.h>   /* inet(3) functions */
#include <stdlib.h>
#include <fcntl.h> // for open
#include <unistd.h> // for close

//最大连接数
#define LISTENQ 1024

//服务器端口号
#define SERV_PORT 9877

//接收和发送的缓冲区大小
#define BUFSIZE 4096

//处理客户端请求函数
void str_echo(int confd);

int main(int argc, char **argv)
{
        int confd, listenfd;
        struct sockaddr_in cliaddr, servaddr;
        pid_t childpid;
        socklen_t clilen;
        int status;
        char buff[BUFSIZE];

        //设置协议地址结构内容
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
        servaddr.sin_port = htons(SERV_PORT);

        listenfd = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
        bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));//将协议、IP地址、端口绑定到套接字
        listen(listenfd, LISTENQ);//使套接字变为监听套接字
        while (1)
        {
            clilen = sizeof(cliaddr);//这一步最容易忘记
            confd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);//等待连接完成
            printf("client fd:%d\n", confd);
            write(confd,buff,strlen(buff));

            if ( (childpid = fork()) == 0) {    /* child process */
                printf("connection from %s, port %d\n",
                        inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
                                ntohs(cliaddr.sin_port));
                        close(listenfd);//子进程不需要监听套接字
                        str_echo(confd);//子进程处理客户端请求
                        close(confd);//处理结束,关闭连接套接字
                        exit(0);//处理结束,关闭子进程

            }
            close(confd); /* parent closes connected socket */
        }
}

void str_echo(int confd)
{
    ssize_t     n;
    char        buf[BUFSIZE];

again:
    while ( (n = read(confd, buf, BUFSIZE)) > 0)
        // printf("%s\n", buf);
        write(confd, buf, n);

    if (n < 0)
        goto again;
}

接下来分析一下代码

if ( (childpid = fork()) == 0){} //派生出子进程

 inet_ntop(AF_INET, &cliaddr.sin_addr, buff, sizeof(buff)),
                                ntohs(cliaddr.sin_port));
//就是把网络需要的IP和端口,转为可读的
close(listenfd);//子进程不需要监听套接字
str_echo(confd);//子进程处理客户端请求
close(confd);//处理结束,关闭连接套接字

因为子进程会继承父进程的全部资源,包括套接字。如果不关闭的话,如果有新的链接,内核会一起推给父进程和子进程。子进程不需要监听套接字,所以,要把它关闭

if ( (childpid = fork()) == 0){
    ...
}
close(confd); //父进程关闭链接

你可能好奇,子进程还在处理链接时,父进程就直接关闭链接,那子进程后续怎么跟客户端沟通?

描述符计数器,父进程关闭链接只会导致相应描述符的引用计数器减1,当计数为0时,才会真正的销毁掉。

3.3 实际操作一下

发表评论

电子邮件地址不会被公开。 必填项已用*标注