【socket】socket介绍-linux下socket常见开发模式

版权声明:本文为博主原创文章,转载请注明来源。 https://blog.csdn.net/s120922718/article/details/43025553

最近在看redis源码,发现它的网络通信部分是用到自己封装的anet框架,把底层的socket的建立、处理等都封装起来。除此之外,在客户端与服务器端之间的通信协议也不是简单的基于TCP/IP的使用,也是通过自己实现的协议满足自己的使用场景。于是对底层的socket通信想了解一下,就看了相关内容,了解socket之间的通信是如何进行。

socket介绍

socket可以理解成是一种特殊的接口,这种特殊的接口是面向进程进行信息的传递和交互一种特殊方式。通过socket,可以在一个主机进程之间或者不同主机之间进程的通信。现在大部分的网络之间的通信底层都是基于socket进行信息的交互。大部分的网络框架也是通过封装socket通信对外暴露更友好的接口提供更丰富的功能。

当使用socket进行主机之间通信,基于socket的应用开发是处于TCP/IP中的传输层之上进行数据传递的。在传输层锁使用的协议有TCP和UDP,所以通信根据协议划分可分为基于流的TCP通信和基于数据报的UDP通信。两个协议之间的区别大家都知道一个是面向连接一个是面向无连接的。因为此差别,在开发的时候也会有不同的处理方式。

下面看到一个不错的图片很清晰的解释了socke所处于的层次:


可以看到通过socket接口层,底层的传输协议的不同就可以被屏蔽掉,直接面向接口层进行编程。接下来就简单讲述socket的具体实现。

socket实现

本节主要讲述的是基于socket的server与client的实现。
针对一个server的实现有很多的技术方式实现,可以实现一个最简单的服务端。监听端口号->接受客户端请求->处理请求->返回给客户端数据->close掉该链接->服务器端继续监听请求。但是在很多时候,服务端不仅仅是接受请求处理返回这么简单,针对服务端还有其他更多需要考虑的地方。
比如当请求太多,服务端接收到请求开始处理。如果该请求处理时间过长,当再有其他请求过来的时候,服务端处理不过来,肯定会阻塞在这里,导致客户端无法获取到返回数据。所以服务端在实现的时候是否需要多进程或者多线程那。
当请求更多的时候,每个请求处理都产生一个进程或者线程的话,那么内核在进程之间切换和进程资源回收的时候,所耗费的时间也会对服务端的性能产生影响。这个时候就需要更加快速的服务端实现。比如select方式等。
还有当客户端异常断开、或者当服务器异常退出的时候,如何处理。服务器端编程是一个复杂的过程,在这里由易入难慢慢展开介绍。

简单的C/S实现

在这种模式下,就是简单的实现服务端接受客户端请求,然后返回客户端数据,处理结束。服务端继续上面流程,直到程序退出。

server

//head file

#ifndef SERVER_H
#define SERVER_H
#include <stdlib.h>
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <iostream>
#include <string.h>
using namespace std;
class Server
{
    public:
        Server();
        int start_server();
    private:
        const static int port;
};
#endif

//cpp
#include "s_server.h"
const int Server::port = 9191;
Server::Server()
{}
int Server::start_server()
{
    int length;
    int sock, sock_peer;
    struct sockaddr_in server;
    sock = socket(AF_INET, SOCK_STREAM, 0); 
    if(sock < 0) cerr<< "creat sock failed" << endl;
    length = sizeof(server);
    memset(&server, 0, length);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = INADDR_ANY;
    server.sin_port = htons(port);
    if(bind(sock, (struct sockaddr *)&server, length) < 0)
        cerr << "failed bind port" << endl;
    listen(sock, 5); //5 is the max pending connection,if pending num bigger than 5, return ECONNREFUSED to client
    int len;
    while(1) { 
        sock_peer = accept(sock, (struct sockaddr *) NULL, NULL); 
        if(sock_peer < 0) 
            cerr << "failed accept peer connection request" << endl; 
        write(sock_peer, "test", 4); 
        close(sock_peer); 
    } 
}
int main(){ 
    Server s; 
    s.start_server(); 
    return 1;
}


client
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <iostream>
#include <string.h>
using namespace std;
int main()
{
    int port = 9191;
    int length;
    int sock;
    sock = socket(AF_INET, SOCK_STREAM, 0); 
    if(sock < 0) cerr<< "creat sock failed" << endl;
    struct sockaddr_in server;
    length = sizeof(server);
    memset(&server, 0, length);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = inet_addr("127.0.0.1");
    server.sin_port = htons(port);
    if(connect(sock, (struct sockaddr *)&server, length) < 0)
        cerr << "connect server failed!!!" << endl;
    char buf[256];
    read(sock, buf, 255);
    cout << buf<< endl;
}

上面代码就实现了最简单的一个基于TCP的socket通信,一个服务器接受客户端请求,一个客户端发送请求。

下面对上面代码进行简单介绍。首先对于server端进行介绍。
server端首先通过调用socket方法建立一个socket,socket接受三个参数,分别是协议族(ipv4、ipv6、本地socket等),类型(流和数据包)和协议类型(一般为0)。前面两个参数很容易理解,第三个参数协议类型是指基于当前的socket的实现。一般情况下一个socket就有唯一的实现,比如基于ipv4的流的socket实现,那么明显就是TCP。但是有些时候可能基于一个socket有多种实现,这种情况就必须指定协议类型数值。
建立一个socket之后,需要对sockaddr_in server进行赋值,该结构体如下:
struct sockaddr_in
{
  short   sin_family; /* must be AF_INET */
  u_short sin_port;
  struct  in_addr sin_addr;
  char    sin_zero[8]; /* Not used, must be zero */
};
通过注释可以很容易理解到每个字段的含义。这里主要是介绍一下in_addr字段,这个字段是用来说明创建socket以后,该socket具体监听哪个ip和端口号。因为你创建一个socket以后必须告诉系统,该socket监听哪个ip、哪个端口号。在单网卡上的机器上其实没什么需要注意的。但是有些服务器是有很多网卡的,所以这个字段必须指明监听细节。一般都是直接赋值为INADDR_ANY,这个在机器上都是0.0.0.0,意思是监听本机所有网卡上的ip。

创建好socket以后,接下来就是调用bind方法绑定一个端口号,通过该端口号接受客户端的请求。绑定成功以后就开始通过listen监听该端口号,调用accept开始阻塞一直到有请求到来。然后读取数据,对读取的数据做操作,然后写回数据。如此可以看到socket的在服务器端的流程是创建socket->bind->listen->accept->handle req
注意上面这个处理次序是不能颠倒也不能缺少的,否则一个socket就创建失败。

对于客户端就比较简单了,下面介绍一下客户端的处理过程。
客户端必须知道服务器端的IP和端口号才可以进行连接,发送数据。客户端构造一个socketaddr_in结构体server,通过调用connect方法就可以连接服务器,连接成功就可以对服务器发送数据,然后读取服务器写回的数据。
这样客户端和server端之间的一次通信就结束了。

上面是基于TCP的socket的通信,基于UDP的处理也是差不多。首先建立一个socket,然后bind端口号。接下来需要注意的是UDP是基于无连接的。因此就不再需要listen和accept操作。当bind成功之后,直接调用recvfrom方法,阻塞住直到有数据传递到该端口号。

多进程实现

上面是一个最简单的基于socket的C/S的实现,服务器启动以后阻塞住开始监听端口,当有请求来的时候在当前进程处理请求,然后返回客户端数据。像上面讲解到,当该处理需要花费较长时间的时候,客户端就会长时间接受不到服务器返回数据。除此之外,当其他新的请求到来的时候一会阻塞住无法处理数据。根据此类情况,服务端的设计必须优化,以更快速的响应客户端的请求。

接下来就讲解一种多进程的实现。当一个请求到来服务端接受的时候,服务端会fork一个进程去处理当前请求。然后服务端继续监听端口,接受其他新的请求。通过多进程实现,就可以避免当服务端处理一个请求的时候,无法接受新的请求的瓶颈。

int Server::start_server_p()
{
    int length;
    int sock, sock_peer;
    struct sockaddr_in server;
    sock = socket(AF_INET, SOCK_STREAM, 0); 
    if(sock < 0) cerr<< "creat sock failed" << endl;
    length = sizeof(server);
    memset(&server, 0, length);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = INADDR_ANY;
    server.sin_port = htons(port);
    if(bind(sock, (struct sockaddr *)&server, length) < 0)
        cerr << "failed bind port" << endl;
    listen(sock, 5); 
    int len;
    while(1)
    {   
        sock_peer = accept(sock,  (struct sockaddr *) NULL, NULL);
        if(sock_peer < 0)
            cerr << "failed accept peer connection request" << endl;
        int pid = fork();
        if(pid == -1) 
            cerr << "create process faild!!!" << endl;
        else
        {   
            if(pid == 0)
            {   
                close(sock);
                handle_req(sock_peer);
                exit(0);
            }   
            else
            {   
                // in parent process, the pid is child pid
                cout << "pid:" << pid << endl;
            }   
        }   
    }   
}

从上述代码可以看到socket的在服务器端的流程是创建socket->bind->listen->accept->handle req变为了socket->bind->listen->accept->new process handle。accept成功之后创建一个进程处理,不影响原来的服务进程接受新的请求。

基于select实现

通过多进程的实现,可以很容易的解决掉,当一个请求需要处理很久时间而阻塞住服务器处理导致的服务器无法再接受请求的问题。但是,当连接过多的时候多进程的实现也会导致相关问题出现-进程的调度问题。当有比较多的进程的时候,有可能CPU把大部分时间都花在了进程的创建,进程的调度、销毁等操作上,导致没有足够世间去处理用户的请求。这势必也会导致服务端的吞吐量性能下降。
因为上面的问题,就又出现了另外一种时间,I/O复用。下面就介绍一种基于select模式。

int Server::start_server_s()
{
    int length;
    int sock, sock_peer;
    struct sockaddr_in server;
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if(sock < 0) cerr<< "creat sock failed" << endl;
    length = sizeof(server);
    memset(&server, 0, length);
    server.sin_family = AF_INET;
    server.sin_addr.s_addr = INADDR_ANY;
    server.sin_port = htons(port);
    if(bind(sock, (struct sockaddr *)&server, length) < 0)
        cerr << "failed bind port" << endl;
    listen(sock, 5); 

    if(-1 == fcntl(sock, F_SETFD, O_NONBLOCK))
    {   
        cerr << "fcntl error!!!";
        exit(1);
    }   

    fd_set master, readfds;
    FD_ZERO(&master);
    FD_ZERO(&readfds);
    FD_SET(sock, &master);
    cout<< "sock is :" << sock << endl;
    int maxfd = sock;
    int ready = -1; 
    int bytes = -1; 
    char buf[256];
    while(1)
    {   
        memcpy(&readfds, &master, sizeof(master));
        if(-1 == (ready = select(maxfd + 1, &readfds, NULL, NULL, NULL)))
        {   
            cerr << "select sock failed!!!" << endl;
            exit(0);
        }   
        cout<< "ready: " << ready << ", sock: " << sock <<  ", maxfd:" << maxfd << endl;
        for(int i = 0; i <= maxfd && ready > 0; i ++) 
        {   
            if(FD_ISSET(i, &readfds))
            {   
                ready --; 
                if(i == sock)
                {   
                    cout << "accpet new sock, the i is " << i << endl;
                    int sock_peer = accept(sock, NULL, NULL);
                    if(sock_peer < 0)
                    {   
                        cerr << "accept sock_peer failed!!!" << endl;
                        exit(0);
                    }   
                    if(-1 == fcntl(sock, F_SETFD, O_NONBLOCK))
                    {   
                        cerr << "fcntl sokc_peer error!!!";
                        exit(1);
                    }   
                    FD_SET(sock_peer, &master);
                    if(maxfd < sock_peer)
                        maxfd = sock_peer;
                }   
                else
                {
                    cout << "recv data, the i is " << i << endl;
                    handle_req(i);
                    FD_CLR(i, &master);                                                                                                                                                                                    

                }
            }
        }
    }
}
             

针对IO复用模式的实现,也有几个固定的步骤需要遵守。在讲解之前要先对IO复用所使用到的一些基础知识做一些介绍。
  • fd_set:一个存放文件描述符的数据结构,其实底层是一个数组。在IO复用处理过程中是对这个数据结构做相应操作
  • FD_SET:宏定义,注意是大写,这个是对fd_set进行添加操作
  • FD_ZERO:宏定义,讲fd_set清空,不包含任何文件描述符
  • FD_CLR:宏定义,从fd_set中删除一个给定的文件描述符
通过上面简单的介绍可以看出,在IO复用中需要三个宏和一个数据结构。在启动服务的中,需要socket->bind->listen几个步骤,但你在创建成功以后和上面的单进程、多进程处理方式就不同。它要首先初始化fd_set数据结构,把需要监听的文件描述符放进fd_set中,接下来调用select操作。
其中select函数的原型是
 int select(int maxfd, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);
分别对每个参数进行简单介绍。
第一个参数maxfd,其是中间三个参数里的文件描述符的最大值加1,其参数的作用是告诉内核我只关注文件描述符在maxfd以下的文件事件,其他的我就不关注了,这可以在很大程度上降低内核监听的范围。当然这个值也可以使用系统提供的默认值FT_SETSIZE,但是没有任何必要,因为这个值太大了,常见的进程中根本使用不到这么多的描述符。
中间三个参数readfds、writefds、exceptfds分别是要监听的事件种类,可以是都是NULL。当都是NULL的时候则监听所有时间,也可以其中某一个或者几个不是NULL,当某个不是NULL的时候,则监听具体类型的事件。
最后一个参数timeout是告诉内核等待的时间,当为NULL的时候,则标识永远监听等待事件的发生;当值为0的时候,则不等待测试完所有描述符立即返回;不为0的时候,那么在超过等待时间或者已经有事件ready的时候返回。

其实select也会有有些弊端,下面就是终极解决方案,采用epoll模式。

基于epoll的实现

其实epoll是linux上的更好的解决方案,在其他操作系统也有类似epoll的方式。比如kqueue,poll等。这里先对select的弊端进行简单介绍。提到select的弊端就不得不提到C10K问题。简单来说就是在有段时间,因为机器和一些实现问题一个服务器无法抗住10K个链接同时请求。核心影响因素是因为select会维护一个socket fd列表,每次有请求来的时候都需要遍历这个列表。其实很多fd中,只有几个是准备好的fd,所以遍历一遍会浪费很多计算。
除此之外,select的限制是单个进程无法打开很多的文件描述符。这个限制是在系统安装的时候就已经确认了。如果想修改打开文件描述符的大小,就必须修改系统文件中的宏配置,然后重新编译内核才可以。然而针对epoll则没有这种限制。

资料补充

在socket开发中,一些用到的方法和数据结构在这里作一个简单的介绍。

socketaddr vs socketaddr_in

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};

/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};

struct sockaddr {
               sa_family_t sa_family;
               char        sa_data[14];
           }


这两个数据结构都封装了socket在connct、bind等相关方法中所需要的ip、端口号和协议等相关内容。区别是sockaddr_in封装了ipv4相关的属性,还有sockaddr_in6是对于ipv6的相关属性的定义。在实际的底层协议中除了基于ipv4和ipv6的端口实现之外,还有其他各种协议实现。对于底层方法不可能对每种协议都有一个方法进行处理。为解决ipv4和ipv6不同的数据结构的问题,出现了sockaddr结构体。通过该结构体对所有的协议结构体进行抽象,对于bind、connect等方法都是接受sockaddr指针作为参数,而不是sockaddr_in或者sockaddr_in6。这也导致问题,在调用socket相关函数需要传递相关结构体的时候需要先对从参数进行强制类型转换,转换成socketaddr形式的结构体;否则就会给出出错或者警告信息。

除了这两种结构体之外,其实还存在另外一种结构体addrinfo,它是通过getaddrinfo方法,可以获取相应的参数。在addrinfo数据结构中也封装有socket建立相关属性。这三个数据结构,在不同的场合下可以使用不同的数据结构。


write vs send vs sendto
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t write(int sockfd, const void *buf, size_t len, int flags);

其中上面的三个函数是只面向socket的函数,而write则不一定是只使用在socket中。在所有的文件读写中都可以使用write函数,只要传入一个文件的描述符就可以,无论这个描述符是一个sock还是一个真是的文件fd。

虽然是两种不同类型的函数,但是他们之间在使用上也有一定的共同点。有时候通过一些参数的控制,不同函数之间其实在原理上就达到了同样的效果。比如 针对send与write函数来说,当send的falgs参数为0的时候,那么send函数与write函数在功能上则是相同的,都是向一个socke写数据。对于send和sendto在一定程度上也有相同之处。当两个方法参数在某种情况下的时候send与sendto所起到的作用是一样的。

recv vs recvfrom vs recvmsg

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

之前介绍的几个函数是针对socket发送的函数,而上面几个函数则是用来从socket中读取的函数。
上述三个函数主要区别就是recvfrom和recvmeg从一个socket读取数据的时候,socket可能已经不在连接状态,比如udp的时候。recv则是面向连接的socket读取数据的。


阅读更多
换一批

没有更多推荐了,返回首页