Unix/Linux编程: Socket API

socket是一种IPC方法,它允许位于同一主机或者使用网络连接起来的不同主机上的应用程序之间交换数据。

关键的 socket 系统调用包括以下几种。

  • socket()系统调用创建一个新 socket。
  • bind()系统调用将一个 socket 绑定到一个地址上。通常,服务器需要使用这个调用来将其 socket 绑定到一个众所周知的地址上使得客户端能够定位到该 socket 上。
  • listen()系统调用允许一个流 socket 接受来自其他 socket 的接入连接。
  • accept()系统调用在一个监听流 socket 上接受来自一个对等应用程序的连接,并可选地返回对等 socket 的地址。
  • connect()系统调用建立与另一个 socket 之间的连接。

socket I/O 可以使用传统的 read()和 write()系统调用或使用一组 socket 特有的系统调用(如send()、recv()、sendto()以及 recvfrom())来完成。在默认情况下,这些系统调用在 I/O 操作无法被立即完成时会阻塞。通过使用 fcntl() F_SETFL 操作来启用 O_NONBLOCK 打开文件状态标记可以执行非阻塞 I/O。

在linux上可以通过调用ioctl(fd, FIONREAD, &cnt)来获取文件描述符fd引用的流socket中可用的未读字节数。对于数据报socket来讲,这个操作会返回下一个未读数据报中的字节数(如果下一个数据报的长度为零的话就返回零)或在没有未决数据报的情况下返回 0。这种特性没有在 SUSv3 中予以规定。

socket

API

在一个典型的客户端/服务器常见中,应用程序使用socket进行通信的方式如下:

  • 各个应用程序创建一个socket。socket是一个允许通信的"设备",两个应用程序都需要用到它
  • 服务器将自己的socket绑定到一个众所周知的地址上使得客户端能够定位到它的位置

使用socket()系统调用能够创建一个socket,它返回一个用来在后继系统调用中应用该socket的文件描述符

NAME
       socket -创建一个新 socket

SYNOPSIS
       #include           /* See NOTES */
       #include 

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

DESCRIPTION
	  domain: 通信域
	  
	  type:类型
	  
	  protocal:通信范围

RETURN VALUE
	  如果正确,返回一一个非负int类型的值,表示该socket实例唯一标识符的文件描述符。这个值可以调用其他系统调用来进行各种操作,比如绑定和监听端口、发送数据等。套接字将处于close状态

	  如果失败,返回-1,并设置error

(1)socket存在于一个通信domain中,它用于确定确定:

  • 识别出一个socket的方法(即 socket“地址”的格式)
  • 通信范围(即是在位于同一主机上的应用程序之间还是在位于使用一个网络连接起来的不同主机上的应用程序之间)

其取值如下

domain 说明 应用程序间的通信 地址格式
AF_INET IPv4协议 通过 IPv4 网络连接起来的主机 128 位 IPv6 地址+16 位端口号
AF_INET6 IPv6协议 通过 IPv6 网络连接起来的主机 32 位 IPv4 地址+16 位端口号
AF_UNIX, AF_LOCAL Unix域协议 同一主机(内核中) 路径名
AF_ROUTE 路由套接字
AF_KEY 密钥套接字

关于AF和PF:AF_前缀表示地址族,PF_前缀表示协议族 ,因为历史上曾想让一个协议族(PF)支持多个地址族(AF),用PF来创建套接字,用AF来创建套接字地址结构,然而就只是想想,没有实现。现在AF和PF的值是相等的。

现代操作系统至少应该支持如下domain:

  • UNIX (AF_UNIX) domain运行在同一主机上的应用程序之间进行通信
  • IPv4 (AF_INET) domain 允许在使用因特网协议第 4 版(IPv4)网络连接起来的主机上的应用程序之间进行通信
  • IPv6 (AF_INET6) domain 允许在使用因特网协议第 6 版(IPv6)网络连接起来的主机上的应用程序之间进行通信

(2)type取值

type 说明
SOCK_STREAM 字节流套接字
SOCK_DGRAM 数据报套接字
SOCK_SEQPACKET 有序分组套接字
SOCK_RAM 原始套接字

注意:自Linux内核2.6.17起,type参数可以接受SOCK_STREAM和SOCK_DGRAM类型与SOCK_NONBLOCKSOCK_CLOEXEC相与。它们分别表示将新创建的socket设为非阻塞的从而无需通过调用 fcntl()来取得同样的结果),以及用fork调用创建子进程时在子进程中关闭该socket。

每个 socket 实现都至少提供了两种 socket:流和数据报,其属性总结如下:

属性 数据报
可靠地递送?
消息边界保留?
面向连接?

流socket(SOCK_STREAM)提供了一个可靠的双向字节流通信信道:

  • 可靠的:表示可以保证发送者传输的数据会完整无缺的到达接收应用程序(假设网络链接和接收者都不会崩溃)或者收到一个传输失败的通知
  • 双向的:表示数据可以在两个socket之间的任意方向上传输
  • 字节流:表示与管道一样一寸照消息边界的概念

(3)protocol取值

protocol 说明
IPPROTO_CP(ipproto) TCP传输协议
IPPROTE_UDP UDP传输协议
IPPROTO_SCTP SCTP传输协议

一般都设置为0,因为前两个参数已经完全决定了它的值

判断套接字类型: AF_INET、AF_INET6 或 AF_UNIX

/**
 * 取得套接字的类型:是网络套接字还是域套接字
 * @param fd {ACL_SOCKET} 网络套接字
 * @return {int} -1: 表示出错或输入非法或非套接字; >= 0 表示成功获得套接字
 *  类型,返回值有 AF_INET、AF_INET6 或 AF_UNIX(仅限 UNIX 平台)
 */
int acl_getsocktype(ACL_SOCKET fd)
{
	ACL_SOCKADDR addr;
	struct sockaddr *sa = (struct sockaddr*) &addr;
	socklen_t len = sizeof(addr);

	if (fd == ACL_SOCKET_INVALID) {
		return -1;
	}

	if (getsockname(fd, sa, &len) == -1) {
		return -1;
	}

#ifdef	ACL_UNIX
	if (sa->sa_family == AF_UNIX) {
		return AF_UNIX;
	}
#endif

#ifdef AF_INET6
	if (sa->sa_family == AF_INET || sa->sa_family == AF_INET6) {
#else
	if (sa->sa_family == AF_INET) {
#endif
		return sa->sa_family;
	}
	return -1;
}

socket分为:流socket和数据报socket

套接字分为两种:

  • 流socket:
    • 流socket的正常通信需要一对相互连接的socket
    • 因此流socket被称为面向连接的。
  • 报文socket:
    • 数据报socket(SOCK_DGRAM)允许数据以被称为数据报的消息的形式进行交换。
    • 在数据报socket中,消息边界得到了保留,但数据传输是不可靠的,消息的到达可能是无序的、重复或者根本就无法到达的
    • 数据报socket是无连接的socket。与流socket不同,一个数据报socket在使用时无需与另一个socket连接。

在 Internet domain 中,数据报 socket 使用了用户数据报协议(UDP),而流 socket 则(通常)使用了传输控制协议(TCP)。一般来讲,在称呼这两种 socket 时不会使用术语“Internet domain数据报 socket”和“Internet domain 流 socket”,而是分别使用术语“UDP socket”和“TCP socket”

流socket分为:主动套接字/被动套接字

流socket通常可以分为主动和被动两种:

  • 默认情况下,使用socket()是主动的。一个主动socket可以用在connect()调用中来建立一个到一个被动 socket 的连接。这种行为被称为执行一个主动的打开。
  • 一个被动 socket(也被称为监听 socket)是一个通过调用 listen()以被标记成允许接入连接的 socket。接受一个接入连接通常被称为执行一个被动的打开。

在大多数使用流 socket 的应用程序中,服务器会执行被动式打开,而客户端会执行主动式打开。

Unix/Linux编程: Socket API_第1张图片

下面 可以判断 :是监听套接字(listen)还是非监听套接字(connect)

/**
 * 检查套接字:是监听套接字还是网络套接字
 * @param fd {ACL_SOCKET} 套接字句柄
 * @return {int} 返回 -1 表示该句柄非套接字,1 为监听套接字,0 为非监听套接字
 */
int acl_check_socket(ACL_SOCKET fd);

/**
 * 判断套接字是否为监听套接字
 * @param fd {ACL_SOCKET} 套接字句柄
 * @return {int} 返回值 0 表示非监听套接字,非 0 表示为监听套接字
 */
int acl_is_listening_socket(ACL_SOCKET fd);


int acl_check_socket(ACL_SOCKET fd)
{
	int val, ret;
	socklen_t len = sizeof(val);

	ret = getsockopt(fd, SOL_SOCKET, SO_ACCEPTCONN, (void*) &val, &len);
	if (ret == -1) {
		return -1;
	} else if (val) {
		return 1;
	} else {
		return 0;
	}
}

int acl_is_listening_socket(ACL_SOCKET fd)
{
	return acl_check_socket(fd) == 1;
}

一对连接的流socket在两个端点之间提供了一个双向通信通道。如下:
Unix/Linux编程: Socket API_第2张图片
连接流socket上IO的语义与管道上IO的语义类似:

  • 要执行IO需要使用read()和write()系统调用(或者socket特有的send()和recv()调用)。由于socket是双向的,因此在连接的两端都可以使用这两个调用。
  • 一个socket可以使用close()系统调用来关闭或者在应用程序终止之后关闭。
    • 之后当对等的应用程序试图从连接的另一端读取数据时将会收到文件结束(当所有缓冲数据都被读取之后)。
    • 如果对等应用程序试图向其socket写入数据,那么它就会收到一个SIGPIPE信号,并且返回EPIPE错误。
    • 处理这种错误的常见方法是忽略 SIGPIPE 信号并通过 EPIPE 错误找出被关闭的连接。

探测套接字中系统缓存区的数据长度

  • 在学习ioctl 时常常跟 read, write 混淆。其实 ioctl 是用来设置硬件控制寄存器,或者读取硬件状态寄存器的数值之类的
  • 而read,write 是把数据丢入缓冲区,硬件的驱动从缓冲区读取数据一个个发送或者把接收的数据送入缓冲区
 ioctl(keyFd, FIONREAD, &b)
  • 得到缓冲区里有多少字节要被读取,然后将字节数放入b里面。接下来就可以用read了。
read(keyFd, &b, sizeof(b))

可以,可以通过ioctl探测套接字中系统缓存区的数据长度

/**
 * 探测套接字中系统缓存区的数据长度
 * @param fd {ACL_SOCKET} 描述符
 * @return {int} 系统缓存区数据长度
 */
int acl_peekfd(ACL_SOCKET fd)
{
	int     count;

	/*
	 * Anticipate a series of system-dependent code fragments.
	 */
#ifdef ACL_UNIX
	return (ioctl(fd, FIONREAD, (char *) &count) < 0 ? -1 : count);
#elif defined(ACL_WINDOWS)
	return (ioctlsocket(fd, FIONREAD, (unsigned long *) &count) < 0
		? -1 : count);
#endif
}

bind

API

NAME
       bind - bind a name to a socket

SYNOPSIS
       #include           /* See NOTES */
       #include 

       int bind(int sockfd, const struct sockaddr *addr,
                socklen_t addrlen);

DESCRIPTION
	bind函数把myaddr所指的soket地址分配给未命名(未绑定)的sockfd文件描述符

	addr 参数是一个指针,它指向了一个指定该 socket 绑定到的地址的结构。传入这个参数的结构的类型
	取决于 socket domain。addrlen 参数指定了地址结构的大小

返回值: 
	成功0,失败-1并设置error

一般来讲,会将一个服务器的 socket 绑定到一个众所周知的地址——即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址。

调用bind函数可以指定一个端口号,或者一个IP地址,也可以两者都指定,或者两者都不指定。

(1)如果一个TCP客户端/服务器没有使用bind绑定一个端口,当调用connect或者listen的时候,内核就会为相应的套接字选择一个临时端口:

  • TCP客户端使用临时端口很常见
  • TCP服务器一般必须一个固定端口,不然其他客户端就不能去连接这个服务器了
  • 一个例外是远程过程调用RPC服务器,它们通常就由内核为她们监听套接字选择一个临时端口(可以使用 getsockname()来获取 socket 的地址),在这种场景中,服务器必须要发布其地址使得客户端能够知道如何定位到服务器的 socket。这种发布可以通过向一个中心目录服务应用程序注册服务器的地址来完成,之后客户端可以通过这个服务来获取服务器的地址。(如 Sun RPC 使用了自己的 portmapper 服务器来解决这个问题。)当然,目录服务应用程序的 socket 必须要位于一个众所周知的地址上。

(2)如果一个TCP客户端/服务器使用bind指定端口0作为固定端口,内核就会在调用bind时选择一个临时端口

(3)我们可以在进程中调用bind为进程指定一个IP地址,不过这个IP地址必须属于其所在主机的网络接口之一。

  • TCP客户端绑定IP地址之后,这个地址就作为该套接字发送IP数据报的源IP地址。

不过TCP客户端一般不手动绑定,内核对自动根据所用外出网络接口来选择源IP地址,而所用外出接口则取决于到达服务器所需的路径。

  • TCP服务器绑定IP地址之后,这个套接字就只接收目的地址是这个IP地址的客户连接

如果TCP服务器不指定IP地址(INADDR_ANY),内核就会把客户发送SYN的目的IP地址作为服务器的源IP地址

bind失败的常见错误:

  • EACCES:被绑定的地址是受保护的地址,仅超级用户能够访问。比如普通用户将socket绑定要知名端口(0-1023)上时
  • EADDRINUSE:被绑定的地址正在使用中

对于第二个参数sockaddr * addr的理解

bind(int fd, sockaddr * addr, socklen_t len)

sockaddr * addr是一个通用地址格式。注意,虽然接收的是通用地址格式,实际上传入的参数可能是IPv4、IPv6 或者本地套接字格式。bind函数会根据len判断addr该怎么解析,len字段表示的就是传入的地址长度,它是一个可变值。

这里其实可以把 bind 函数理解成这样:

bind(int fd, void * addr, socklen_t len)

不过 BSD 设计套接字的时候大约是 1982 年,那个时候的 C 语言还没有void *的支持,为了解决这个问题,BSD 的设计者们创造性地设计了通用地址格式来作为支持 bind 和accept 等这些函数的参数。

对于使用者来说,每次需要将 IPv4、IPv6 或者本地套接字格式转化为通用套接字格式,就像下面的 IPv4 套接字地址格式的例子一样:

struct sockaddr_in name;
bind (sock, (struct sockaddr *) &name, sizeof (name)

关于通配地址INADDR_ANY

设置bind的时候,对地址和端口有多种处理方式。

  • 我们可以把地址设置成本机的IP地址,这相当于告诉操作系统内核,仅仅对目标IP是本机的IP地址的IP包进行处理
  • 但是这样写的程序在部署的时候有一个问题,我们编写应用程序时并不清楚自己的应用程序会被部署到哪台机器上,这个时候,可以利用通配地址的能力帮助我们解决这个问题
  • 通配地址相当于告诉操作系统内核:““Hi,我可不挑活,只要目标地址是咱们的都可以。””
  • 比如一台机器上有两个网卡,IP地址分别是202.61.22.55和192.168.1.11,那么向这两个IP请求的请求包都会被我们编写的应用程序处理、

那么该如何设置通配地址呢?

  • 对于 IPv4 的地址来说,使用 INADDR_ANY 来完成通配地址的设置;
  • 对于 IPv6 的地址来说,使用 IN6ADDR_ANY 来完成通配地址的设置。
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4 通配地址 */

封装:网络地址绑定函数,适用于 TCP/UDP 套接口

/**
 * 网络地址绑定函数,适用于 TCP/UDP 套接口
 * @param res {const struct addrinfo*} 域名解析得到的地址信息对象
 * @param flag {unsigned int} 标志位
 * @return {ACL_SOCKET} 返回 ACL_SOCKET_INVALID 表示绑定失败
 *
 */
ACL_SOCKET acl_inet_bind(const struct addrinfo *res, unsigned flag)
{
    ACL_SOCKET fd;
    int        on;

    fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (fd == ACL_SOCKET_INVALID) {
        ERROR("%s(%d): create socket %s",
                      __FILE__, __LINE__, strerror(errno));
        return ACL_SOCKET_INVALID;
    }

    if (flag & ACL_INET_FLAG_EXCLUSIVE) {
#if defined(SO_EXCLUSIVEADDRUSE)  // 独占端口不准许别人使用
        on = 1;
		if (setsockopt(fd, SOL_SOCKET, SO_EXCLUSIVEADDRUSE,
			(const void *) &on, sizeof(on)) < 0) {

			WARN("%s(%d): setsockopt(SO_EXCLUSIVEADDRUSE)"
				": %s", __FILE__, __LINE__, strerror(errno));
		}
#endif
    } else {
        on = 1;
        if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR,
                       (const void *) &on, sizeof(on)) < 0) {

            WARN("%s(%d): setsockopt(SO_REUSEADDR): %s",
                         __FILE__, __LINE__, strerror(errno));
        }
    }

#if defined(SO_REUSEPORT)
    on = 1;
    if (flag & ACL_INET_FLAG_REUSEPORT) {
        int ret = setsockopt(fd, SOL_SOCKET, SO_REUSEPORT,
                             (const void *) &on, sizeof(on));
        if (ret < 0)
            WARN("%s(%d): setsocket(SO_REUSEPORT): %s",
                         __FILE__, __LINE__, strerror(errno));
    }
#else
    (void) flag;
#endif

#ifdef ACL_WINDOWS
    if (bind(fd, res->ai_addr, (int) res->ai_addrlen) < 0) {
#else
    if (bind(fd, res->ai_addr, res->ai_addrlen) < 0) {
#endif
        close(fd);
        ERROR("%s(%d): bind error %s",
                      __FILE__, __LINE__,  strerror(errno));
        return ACL_SOCKET_INVALID;
    }

    return fd;
}

	#include 
    struct sockaddr_in servaddr_v4;
    servaddr_v4.sin_addr.s_addr = htonl(INADDR_ANY);

    struct sockaddr_in6 servaddr_v6;
    servaddr_v6.sin6_addr = in6addr_any;


void Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
    if (bind(fd, sa, salen) < 0){
        printf("bind error");
        exit(0);
    }
}

listen

 NAME
      listen - listen for connections on a socket

SYNOPSIS
      #include           /* See NOTES */
      #include 

     int listen(int sockfd, int backlog);

DESCRIPTION
	 listen()系统调用将文件描述符 sockfd 引用的流 socket 标记为被动。这个 socket 后面会被
	 用来接受来自其他(主动的)socket 的连接
	 
	 backlog规定了内核应该为这个套接字排队的最大连接个数

	 无法在一个已连接的 socket(即已经成功执行 connect()的 socket 或由 accept()调用返回的
	 socket)上执行 listen()。

RETURN VALUE
	 成功0, 错误-1并设置error

listen仅由TCP服务器调用,它做两件事:

  • listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接收指向该套接字的连接请求
    • 初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用 connect 函数)
    • 通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。。
    • 调用listen之后,套接字从CLOSED状态转换为LISTEN状态
  • 将创建一个监听队列以存放待处理的客户连接
    • backlog规定了内核应该为这个套接字排队的最大连接个数,也就是说未完成连接队列的大小
    • 监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也收到ECONNREFUSED错误。
    • 也就是说,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也越大。但是参数过大也会占用更多的系统资源

要理解 backlog 参数的用途首先需要注意到客户端可能会在服务器调用 accept()之前调用connect()。这种情况是有可能会发生的,如服务器可能正忙于处理其他客户端。这将会产生一个未决的连接,如下图所示:
Unix/Linux编程: Socket API_第3张图片
内核必须要记录所有未决的链接请求的相关信息,这样后继的accept()就能够处理这些请求了。backlog参数允许限制这种未决连接的数量。

在内核版本2.2之前的linux中,backlog参数是指所有处于半连接状态(SYNC_RCVD)和完全连接状态(ESTABLISHED)的socket的上限。
在内核版本2.2之后,它只表示处于完全连接状态的socket的上限,处于半连接状态的socket的上限则由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数定义。backlog参数的典型值是5

内核会为任何一个给定的监听套接字维护两个队列:

  • 未完成连接队列:当监听套接字收到客户端发来的SYN之后,而服务器还没有和这个连接进行TCP三次握手的时候,这些套接字处理SYN_SEND状态。
  • 已完成连接队列:每个已经完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态
    Unix/Linux编程: Socket API_第4张图片
    每当在未完成队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中,连接的创建机制是完全自动的,无需服务器插手
    Unix/Linux编程: Socket API_第5张图片
  • 在三次握手正常完成的前提下(没有丢失分节,没有重传),未完成连接队列中的任何一项在其中的存留时间是一个RTT,其RTT的值取决于特定的客户与服务器(对于web服务器RTT一般是187ms)。
  • 当一个客户的SYNC到达时,如果这些队列是满的,TCP就暂时忽略该分节,也就是不发送RST,这样做是因为:这种情况是暂时的,客户TCP将重发SYN,期望不久就能在这些队列中找到可用空间。如果服务器TCP立即响应一个RST,客户的connect调用就会立即返回一个错误,强制应用程序处理这种情况,而不是让TCP的正常重传机制来处理。另外,客户无法区别响应SYNC的RST是“该端口没有服务器在监听”还是“该端口由服务器在监听,但是队列满了”
  • 在三次握手完成之后,服务器调用accept之前到达的数据应该由服务器TCP排队,最大数据量为相应已连接套接字的接收缓冲区大小
void Listen(int fd, int backlog)
{
    char	*ptr;

    if ( (ptr = getenv("LISTENQ")) != NULL)
        backlog = atoi(ptr);

    if (listen(fd, backlog) < 0){
        printf("listen error");
        exit(0);
    }
}

问:backlog参数对listen系统调用的实际影响

(1) 先编写服务器程序

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

static bool stop = false;
static void handle_term( int sig )
{
    stop = true;
}

int main( int argc, char* argv[] )
{
    struct sockaddr_in address;
    const char* ip;
    int port, backlog, sock;
    int ret;
    signal( SIGTERM, handle_term );
    
    if( argc <= 3 )
    {
        printf( "usage: %s ip_address port_number backlog\n", basename( argv[0] ) );
        return 1;
    }
    
    ip = argv[1];
    port = atoi( argv[2] );
    backlog = atoi( argv[3] );

    sock = socket( PF_INET, SOCK_STREAM, 0 );
    assert( sock >= 0 );
    
    bzero( &address, sizeof( address ) );
    address.sin_family = AF_INET;
    address.sin_port = htons( port );
    
    inet_pton( AF_INET, ip, &address.sin_addr );
    ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );

    ret = listen( sock, backlog );
    assert( ret != -1 );

    while ( ! stop )
    {
        sleep( 1 );
    }

    close( sock );
    return 0;
}

(2)命令行执行
在这里插入图片描述
并且使用telnet 192.168.0.12 12345多次建立连接,每执行一次就使用netstat -nt | grep 12345来查看状态
Unix/Linux编程: Socket API_第6张图片

accept

accept()系统调用在文件描述符 sockfd 引用的监听流 socket 上接受一个接入连接。如果在调用 accept()时不存在未决的连接,那么调用就会阻塞直到有连接请求到达为止

  • 当客户端的连接请求到达时,服务端应答成功,连接建立,这个时候操作系统内核需要把这个事情通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。
  • accept这个函数的作用就是连接建立之后,操作系统内核和应用程序之间的桥梁
/*
* 参数:
* 	* sockfd: 监听套接字,由socket创建
* 	* cliaddr: 用来返回已连接的客户端的协议地址。如果对客户地址不管兴趣,可以置为NULL
* 	* addrlen:是值-结果参数,调用前,为cliaddr的地址长度,调用后,为内核存放在该地址结果内的确切字节数。如果对客户地址不管兴趣,可以置为NULL
* 返回值: 出错-1,成功返回已连接套接字
*/
int accept (int sockfd, struct sockaddr *__restrict cliaddr,
		   socklen_t *__restrict addr_len)
  • 函数的第一个参数 listensockfd 是套接字,可以叫它为 listen 套接字,因为这就是前面通过 bind,listen 一系列操作而得到的套接字。
  • 函数的返回值有两个部分:
    • 第一个部分cliaddr是通过指针方式获取的客户端的地址,addr_len告诉我们地址的大小。这可以理解为当我们拿起电话机时,看到了来电显示,知道了对方的毫秒
    • 第二个部分是函数的函数时,这个返回值是一个全新的描述字,代表了与客户端的连接。也叫做已连接套接字

监听套接字 VS 已连接套接字:

  • 一个服务器通常只创建一个监听套接字(listen),它在该服务器的生命期内一直存在
  • 内核会为每个服务器进程接收的客户连接创建一个已连接套接字(此时三次握手已完成–ESTABLISTEN)。当服务器完成给定客户的服务时,记得关闭这个已连接套接字

问题:为什么要把两个套接字分开呢?用一个不是挺好的么?

  • 这里和打电话的情况不一样的地方在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“你拨打的电话正在通话中”。而网络程序的一个重要特性就是并发处理,不可能一个应用程序运行只会只能服务一个客户。
  • 所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭
  • 而一旦一个客户和服务器连接成功,完成了TCP三次握手,操作系统内核就为这个客户端生成一个已连接套接字,让应用服务器使用这个已连接套接字和客户进行通信。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是这个已连接套接字,这样就完成了TCP连接的释放。注意,这个时候释放的只是这一个客户连接,其他被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务

问: 如果监听队列中处于ESTABLISTEN状态的连接对应的客户端出现网络异常(比如掉线)或者提前退出,那么服务器对这个连接执行的accept调用是否能够成功?

(1)编写代码

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

int main( int argc, char* argv[] )
{
    if( argc <= 2 )
    {
        printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
        return 1;
    }
    const char* ip = argv[1];
    int port = atoi( argv[2] );

    struct sockaddr_in address;
    bzero( &address, sizeof( address ) );
    address.sin_family = AF_INET;
    inet_pton( AF_INET, ip, &address.sin_addr );
    address.sin_port = htons( port );

    int sock = socket( PF_INET, SOCK_STREAM, 0 );
    assert( sock >= 0 );

    int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );

	sleep(20);  //暂停20s以便客户端连接、掉线或者退出完成

    ret = listen( sock, 5 );
    assert( ret != -1 );

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof( client );
    int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
    if ( connfd < 0 )
    {
        printf( "errno is: %d\n", errno );
    }
    else
    {
        char remote[INET_ADDRSTRLEN ];
        printf( "connected with ip: %s and port: %d\n", 
            inet_ntop( AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN ), ntohs( client.sin_port ) );
        close( connfd );
    }

    close( sock );
    return 0;
}

(2)执行下面操作

  • 服务器主机运行服务端程序: ./fork_process 192.168.0.12 12345
  • 客户端主机连接服务端命令: telnet 192.168.0.12 12345 , 然后断开网络
  • 服务器主机上查看socket的状态: netstat -anp | grep 12345
    在这里插入图片描述
    在这里插入图片描述

总结: accept调用对客户端网络的断开毫不知情

(3) 执行下面操作

  • 服务器主机运行服务端程序: ./fork_process 192.168.0.12 12345
  • 客户端主机连接服务端命令: telnet 192.168.0.12 12345 , 然后关闭客户端连接
  • 服务器主机上查看socket的状态: netstat -anp | grep 12345
    Unix/Linux编程: Socket API_第7张图片
    在这里插入图片描述

Unix/Linux编程: Socket API_第8张图片
总结:accept只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络的变化

econnrefused

带有超时连接的accept

 socket_stream* server_socket::accept(int timeout /* = 0 */, bool* etimed /* = NULL */){
        if (etimed) {
            *etimed = false;
        }

        if (listen_fd_ == nullptr) {
            logger_error("server socket not opened!");
            return NULL;
        }

        if (timeout > 0) {
            if (listen_fd_->acl_read_wait(timeout) == -1) {
                if (etimed) {
                    *etimed = true;
                }
                return NULL;
            }
        }


        acl_socket* conn_fd = listen_fd_->acl_accept(NULL, 0, NULL);
        if (conn_fd == NULL) {
            if (open_flag_ & ACL_NON_BLOCKING) {
                logger_error("accept error %s", strerror(errno));
            } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
                logger_error("accept error %s", strerror(errno));
                return NULL;
            }
        }

        socket_stream *client = new socket_stream();
        return client;
    }

错误

 static int accept_ok_errors[] = {
                EAGAIN,
                ECONNREFUSED,
                ECONNRESET,
                EHOSTDOWN,
                EHOSTUNREACH,
                EINTR,
                ENETDOWN,
                ENETUNREACH,
                ENOTCONN,
                EWOULDBLOCK,
                ENOBUFS,			/* HPUX11 */
                ECONNABORTED,
                0,
        };

EINPROGRESS

操作正在进行中。一个阻塞的操作正在执行。

ECONNREFUSED

  • 拒绝连接。一般发生在连接建立时。
    • 拔服务器端网线测试,客户端设置keep alive时,recv较快返回0, 先收到ECONNREFUSED (Connection refused)错误码,其后都是ETIMEOUT。
  • 从connect()返回的错误,因此它只能发生在客户端(如果客户端被定义为发起连接的一方)

ENOTCONN

  • 在一个没有建立连接的socket上,进行read,write操作会返回这个错误。出错的原因是socket没有标识地址。Setsoc也可能会出错。
  • 还有一种情况就是收到对方发送过来的RST包,系统已经确认连接被断开了

套接字操作需要一个目的地址,但是套接字尚未连接.


我目前正在维护一些网络服务器软件,我需要执行大量的I/O操作。在套接字上使用时,read(),write(),close()和shutdown()调用有时可能会引发ENOTCONN错误。这个错误究竟意味着什么?什么是会触发它的条件?

  • 如果您确定自己已经正确连接,ENOTCONN最有可能是由fd在您处于请求中间的情况下(可能在另一个线程中)被关闭引起的,或者当你处于请求的中间时,通过连接丢弃。 无论如何,这意味着插座没有连接。继续并清理该套接字。它已经死了。调用close()或shutdown()就可以了。https://oomake.com/question/153611

EISCONN

  • 一般是socket客户端已经连接了,但是调用connect,会引起这个错误。
  • 在一个已经连接的套接字上调用 connect(2) 或者指定的目标地址在一个已连接的套接字上.

https://blog.csdn.net/svap1/article/details/70809798

EAGAIN

  • 表示资源不可用,使用非阻塞操作时经常遇到,并不是一种错误

如果连接数目达此上限则client 端将收到ECONNREFUSED 的错

EWOULDBLOCK

用于非阻塞模式,不需要重新读或者写

因为非阻塞,所以,read,write,等等只要不能马上操作就会返回这个错误,它的意思是让软件再尝试直到OK为止。所以,遇见这个错误只要重新再读就可以了

ENOBUFS

可用的缓冲区空间不足。只有释放了足够的资源,才能创建套接字。

ECONNRESET_

连接被远程主机关闭。有以下几种原因:远程主机停止服务,重新启动;当在执行某些操作时遇到失败,因为设置了“keep alive”选项,连接被关闭,一般与ENETRESET一起出现。

1、在客户端服务器程序中,客户端异常退出,并没有回收关闭相关的资源,服务器端会先收到ECONNRESET错误,然后收到EPIPE错误。

2、连接被远程主机关闭。有以下几种原因:远程主机停止服务,重新启动;当在执行某些操作时遇到失败,因为设置了“keep alive”选项,连接被关闭,一般与ENETRESET一起出现。

3、远程端执行了一个“hard”或者“abortive”的关闭。应用程序应该关闭socket,因为它不再可用。当执行在一个UDP socket上时,这个错误表明前一个send操作返回一个ICMP“port unreachable”信息。

4、如果client关闭连接,server端的select并不出错(不返回-1,使用select对唯一一个socket进行non- blocking检测),但是写该socket就会出错,用的是send.错误号:ECONNRESET.读(recv)socket并没有返回错误。

5、该错误被描述为“connection reset by peer”,即“对方复位连接”,这种情况一般发生在服务进程较客户进程提前终止。

ECONNABORTED Software caused connection abort

1、软件导致的连接取消。一个已经建立的连接被host方的软件取消,原因可能是数据传输超时或者是协议错误。

2、该错误被描述为“software caused connection abort”,即“软件引起的连接中止”。原因在于当服务和客户进程在完成用于 TCP 连接的“三次握手”后,客户 TCP 却发送了一个 RST (复位)分节,在服务进程看来,就在该连接已由 TCP 排队,等着服务进程调用 accept 的时候 RST 却到达了。POSIX 规定此时的 errno 值必须 ECONNABORTED。源自 Berkeley 的实现完全在内核中处理中止的连接,服务进程将永远不知道该中止的发生。服务器进程一般可以忽略该错误,直接再次调用accept。

当TCP协议接收到RST数据段,表示连接出现了某种错误,函数read将以错误返回,错误类型为ECONNERESET。并且以后所有在这个套接字上的读操作均返回错误。错误返回时返回值小于0。

https://blog.csdn.net/wuji0447/article/details/78356875

connect

服务器是通过listen调用被动接受连接,而客户端通过connect主动发起连接:

/*
* 功能: TCP客户使用connect函数来建立与TCP服务器的连接
* 返回值: 成功0,出错-1
*/
int connect (int soccket, const struct sockaddr * servaddr, socklen_t len)
  1. 如果客户端在调用connect之前没有使用bind指定IP地址和端口,内核就会自己确定源IP地址,并选择一个临时端口作为源端口
  2. 如果是TCP套接字,调用connect函数将激发TCP的三次握手工程,并且仅在连接建立成功或者出错才返回,其中出错原因可能如下:

(1)ETIMEDOUT

  • 三次连接无法建立,客户端发出的SYN包没有任何响应,返回TIMEDOUT错误
  • 这种情况比较常见的原因是对应的服务端 IP 写错。

如果第一次过6s没有收到响应,过再发一次SYN,24s之后没有响应再发一次。。。75s之后仍没有反应就返回ETIMEDOUT
当然我们可以自己定义超时时间

(2) ECONNREFUSED

  • 客户端收到了RST(复位)(这是一种硬错误(hard error))回答,这个时候客户端(connect)一收到RST就马上返回ECONNREFUSED错误
  • 这种情况比较场常见于客户端发送连接请求时的请求端口写错,因为RST是TCP在发生错误时发生的一种TCP分节。
  • 产生RST的三个条件是:
    • 目的服务器发现该端口上没有相应的进程在等待
    • TCP想取消一个已有连接
    • TCP接收到一个根本不存在的连接上的分节

在这里插入图片描述

(3)EHOSTUNREACHUNETUNREACH

  • 客户端发出SYN时在网络上引起了destination unreachable(目的地不可达)的ICMP错误。
    • 客户端主机内核会保存该消息,并按第一种情况所说的时间间隔继续发送SYN,如果在72s之后仍未收到相应,则把保存的消息(即ICMP)错误作为ehostunreachenetunreach错误返回给进程。
    • (这是一种软错误(soft error)
  • 这种情况比较常见的原因是客户端和服务器路由不通
    在这里插入图片描述

connect函数导致当前套接字从CLOSE状态转移到SYN_SENT状态,如果成功则再转移到established状态。如果connect失败将导致该套接字不可用,必须关闭,我们不能对这样的套接字再次调用connect

(4)ECONNRESET

  • 产生原因:
    • 其实这就是状态机里一个简单的竞争情形:
      • 客户端与服务端成功建立了长连接
      • 连接静默一段时间(无 HTTP 请求)
      • 服务端因为在一段时间内没有收到任何数据,主动关闭了 TCP 连接
      • 客户端在收到 TCP 关闭的信息前,发送了一个新的 HTTP 请求
      • 服务端收到请求后拒绝,客户端报错 ECONNRESET
    • 总结一下就是:服务端先于客户端关闭了 TCP,而客户端此时还未同步状态,所以存在一个错误的暂态(客户端认为 TCP 连接依然在,但实际已经销毁了)
  • 解决方案。有两种方法可选
    • 保障客户端永远先于服务端关闭TCP连接:
      • 这种方法就是把客户端的keep-alive超时时间设置得短一些(短于服务端即可)。这样就可以保证永远是客户端这边超时关闭的TCP连接,消除了错误的暂态
      • 但这样在实际的生成环境中是没法100%解决问题的,因为无论把客户端超时时间设置得多少,因为网络延时的存在,始终无法保证所有的服务端的keep-alive超时时间长于客户端的值;如果把客户端的超时时间设置得太少,又失去了意义。
    • 错误重试
      • 最佳的解决方法还是,如果出现了这种暂态导致的错误,那么重试一次请求就好,但是只识别ECONNRESET这个错误码是不够的,因为服务端可能因为某种原因真的关闭了客户端
      • 所以最佳的做法是,使用一个标记表示当前的请求是否复用了 TCP,如果错误码为 ECONNRESET 且存在标记(复用了 TCP),那么就重试一次。

其他错误: 当我们以非阻塞的方式来进行连接的时候,返回的结果如果是 -1,这并不代表这次连接发生了错误,如果它的返回结果是 EINPROGRESS,那么就代表连接还在进行中。 后面可以通过poll或者select来判断socket是否可写,如果可以写,说明连接完成了。

void Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
    if (connect(fd, sa, salen) < 0){
        printf("connect error");
        exit(0);
    }
}

close函数

#include 
/*
* 功能:  套接字引用计数减1.当为0时立即关闭连接
* 返回值: 成功0,出错-1
*/
 int close (int __fd)

并发服务器中父进程关闭已连接套接字只是导致相应描述符的引用计数值减1。如果引用计数值大于0,这个close调用并不引发TCP的四分组连接终止序列。一旦发现套接字引用计数到0,就会对套接字进行彻底释放,并且会TCP两个方向的数据流

如果我们确实想要在某个TCP连接上发送一个FIN(表示立即终止连接),可以改用shutdown函数。

问:套接字引用计数的含义

因为套接字可以被多个进程共享,可以理解为我们给每个套接字都设置了一个积分,如果我们通过fork的方式产生子进程,套接字就会积分+1,如果我们调用一次close函数,套接字就会积分-1。这就是套结种子引用计数的含义。

问:“TCP两个方向的数据流”怎么理解

TCP是双向的,这里说的方向,指的是数据“写入-流出”的方向。

  • 比如客户端到服务端的方向,指的是客户端通过套接字接口,向服务器端发送TCP报文;而服务端到客户端方向则是另一个传输方向。
  • 在大多数情况下,TCP连接都是先关闭一个方向,此时另外一个方向还可以正常进行数据传输,
  • 举个例子,客户端主动发起连接的中断,将自己到服务端的数据流方向关闭,此时,客户端不在往服务端写入数据,服务端读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP连接已经完全关闭,很有可能是,服务端正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务端把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务端才有条不紊的关闭剩下的半个连接,结束这一段TCP连接的使命。
  • 我这里描述的,是服务器端“优雅”地关闭了连接。如果服务端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务端处理完信息没办法正常传送到客户端,破坏了用户侧的使用场景

问: close函数具体是如何关闭两个方向的数据流呢?

  • 在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
  • 在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个FIN报文,接下来如果再对该套接字进行写操作都会返回异常。
  • 如果对端没有检测到套接字已经关闭,还继续发送报文,就会收到一个RST报文,告诉对端:“hi,我已经关闭了,别再给我发数据了”

问:如果父进程对每个由accept返回的已连接套接字都不调用close,那么在并发服务器中将会发生什么?

  • 父进程将耗尽可用描述符,因为任何进程在任何时刻可拥有打开着的描述符数量是有限的
  • 另外,没有一个客户连接会被终止。当子进程关闭已连接套接字时,它的引用计数器将由2递减为1,又父进程没有close这个套接字,那么这个套接字的引用计数值将一直保持为1,这将妨碍TCP连接终止序列的发生,导致连接一直打开着

shutdown

#include 
/*
* 功能:
* 返回值:成功0,失败-1
*/
int shutdown(int socket, int howto);
  • sock 为需要断开的套接字
  • howto 为断开方式。

howto 在 Linux 下有以下取值:

  • SHUT_RD(0):
    • 关闭连接的“读”这个方向,应用程序不能在针对该socket文件描述符进行读操作,对该套接字进行读操作将会直接返回EOF
    • 从数据角度来看,套接字接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行ACK,然后悄悄的丢弃。
    • 也就是说,对端还是会接收到ACK,在这种情况下根本不知道数据已经被丢弃了
  • SHUT_WR(1)
    • 关闭连接的“写”这个方向。这就是常被称为半关闭的连接状态
    • 此时,不管套接字引用计数的值是多少,都会直接关闭连接的写方向
    • 套结字发送缓冲区已有的数据将被立即发送出去,并发送一个FIN报文给对端
    • 应用程序如果对该套接字进行写操作会报错SIGPIPE
      -SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,同时关闭套接字的读和写两个方向。

问题:使用 SHUT_RDWR 来调用 shutdown 不是和 close 基本一样吗,都是关闭连接的读和写两个方向。

其实,这两个还是有差别的

  • 第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。
  • 第二个差别:close存在引用计数的概念,并不一定导致该套接字不可用;shutdown则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用套接字,将会受到影响。
  • 第三个差别:close的引用计数导致不一定会发出FIN结束报文,而shutdown则总会发出FIN结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的。

综上:

  • close函数会关闭套接字ID,如果有其他的进程共享着这个套接字,那么它仍然是打开的,这个连接仍然可以用来读和写,只是套接字的引用计数减1。
  • shutdown会切断进程共享的套接字的所有连接,不管这个套接字的引用计数是否为零,那些试图读得进程将会接收到EOF标识,那些试图写的进程将会检测到SIGPIPE信号,同时可利用shutdown的第二个参数选择断连的方式
  • shutdown能够分别关闭socket的读/写或者全部关闭。而close只能将socket上的读全部关闭
  • 调用shutdown()只是进行了TCP断开, 并没有释放文件描述符

在大多数情况下,我们会优选 shutdown 来完成对连接一个方向的关闭,待对端处理完之后,再完成另外一个方向的关闭。

整个过程

Unix/Linux编程: Socket API_第9张图片

在客户端发起连接请求之前,服务端必须初始化好。因此,先初始化一个socket,然后执行bind将自己的服务能力绑定到一个众所周知的地址和端口上,紧接着,服务端执行listen操作,将原来的socket转换为服务端的socket,服务端最后阻塞在accept上等待客户端请求的到来

此时,服务端已经准备期就绪。接下来是客户端,客户端必须先初始化socket,再执行connect向服务端的地址和端口发起连接请求,这里的地址和端口必须是客户端预先知晓的。这个过程,就是TCP三次握手。

一旦三次握手完成,客户端和服务端建立连接,就进入了数据传输的过程。

具体来说,客户端进程向操作系统内核发起write字节流写操作,内核协议栈将字节流通过网络设备传输到服务端,服务端从内核得到消息,将字节流从内核读入到进程中,并开始业务逻辑的处理,完成之后,服务端再将得到的结果以同样的方式写给客户端。可以看出,一旦连接建立,数据的传输就不再使单向的,而是双向的,这也是TCP的一个显著特点

当客户端完成和服务端的交互之后,比如执行一次telnet操作,或者一次http请求,需要和服务端断开连接是,就会执行close函数,操作系统内核此时会通过原先的连接链路向服务端发送一个FIN包,服务端收到之后执行被动关闭,这个时候整个链路处于半关闭状态,此后,服务端也会执行close函数,整个链路才会真正关闭。半关闭的状态下,发起close请求的一方在没有收到对方FIN包之前都认为连接是正常的;而在全关闭的状态下,双方都感知连接已经关闭。

可以看到,以上所有的操作,都是通过socket来完成的。无论是客户端的connect,还是服务端的accept,或者read/write操作等,socket是我们用来建立连接,传输数据的唯一途径

参考

你可能感兴趣的:(Unix/Linux编程,linux,unix,服务器)