TCP套接字编程

参考:《UNIX 网络编程 · 卷1 : 套接字联网API》

基本的套接字编程

socket函数

想要执行网络 I/O,首先需要调用 socket 函数创建套接字,需要头文件 #include <sys/socket.h>

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

参数

  • domain: 执行协议域,取值如下:
domain说明
AF_INETIPV4协议
AF_INET6IPV6协议
AF_LOCALUnix 域协议
AF_ROUTE路由套接字
AF_KEY密钥套接字
  • type : 套接字类型,取值如下
type说明
SOCK_STREAM字节流套接字
SOCK_DGRAM数据报套接字
SOCK_SEQPACKET有序分组套接字
SOCK_RAW原始套接字
  • protocol : 某个协议类型常值,或者设置为 0。
protocal说明
IPPROTO_TCPTCP传输协议
IPPROTO_UDPUDP传输协议
IPPROTO_SCTPSCTP传输协议

如果使用默认,protocol 可以填0。

返回值

  成功时返回一个 int 型整数,是一个类似于文件描述符的网络套接字描述符。

  出错返回 -1。

注:在本函数中,domaintype 的组合不一定有效,有效的组合如下所示:

AF_INETAF_INET6AF_LOCALAF_ROUTEAF_KEY
SOCK_STREAMTCP / SCTPTCP / SCTP
SOCK_DGRAMUDPUDP
SOCK_SEQPACKETSCTPSCTP
SOCK_RAWIPV4IPV6

其中 domain 还有以 PF_ 开头的,还有其他 BSD 支持的协议域,不过多介绍。表格中“是”表示可以组合,但是没有有效的名称,空白是无效组合。

connect 函数(客户端)

如果客户端想要连接服务器,必须使用 connect 函数完成,需要引入头文件 #include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

参数

  • sockfd : 是之前 socket 函数返回的套接字描述符。
  • servaddr : 指向服务器套接字地址结构体的指针,必须含有服务器的 IP 地址和端口。
  • addrlen : 指向服务器套接字地址结构体的大小。

返回值
  成功,返回 0;
​  失败,返回 -1,并设置 error 值。

注意

  1. 客户端在调用 connect 前不一定非要调用 bind 函数,因为内核会确定源 IP 地址,并选择一个临时端口作为源端口。
  2. 如果是 TCP 套接字,调用 connect 函数将激发三次握手过程。在连接建立成功或出错时返回。出错情况如下:
      ETIMEDOUT : 客户端没有收到 SYN 分节的响应。
      ECONNREFUSED : 服务器对客户端 SYN 的响应是 RST(复位),说明服务器在指定的端口上没有进程在等待与之连接(硬件错)。
      EHOSTUNREACH / ENETUNREACH : 客户端发送的 SYN 在某个路由器引发了目的地不可达的 ICMP 错误(软错误)。

bind函数

如果想要将 socket 函数创建的套接字与一个本地协议地址捆绑起来,需要使用 bind 函数,头文件 #include <sys/types.h>#include <sys/socket.h>

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

参数

  • sockfd : 调用socket函数返回的套接字。
  • myaddr : 指向特定协议地址结构体的指针。
  • addrlen : 特定协议地址结构体的大小。

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

  出错情况:

    EADDRINUSE : 地址已经使用

注意

  1. bind 函数捆绑时,可以指定 IP 和端口,也可以不指定,不指定的话内核会要为相应的套接字选择一个临时端口,但是如果是服务器创建出来的套接字地址信息客户端不会知道。

  2. bind 指定 IP 端口和不指定产生的结果如下:

    IP端口结果
    非指定0内核选择IP和端口
    非指定非0内核选择IP,进程指定端口
    指定本机IP0进程指定IP,内核选择端口
    指定本机IP非0进程指定IP和端口
  3. 如果想指定地址为通配地址,IPV4 为 INADDR_ANY,IPV6 为 in6addr_any,头文件为 #include <netinet/in.h>

    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);	//ipv4
    servaddr.sin6_addr = in6addr_any;	//ipv6
    
  4. bind 并不返回内核所选择的临时端口的值。因为 const 限定,如果想要得到内核选择的临时端口,需要使用 getsockname 来返回协议地址。

listen函数(服务器)

服务器如果需要监听客户端连接,需要使用 listen 函数,头文件 #include <sys/socket.h>

int listen(int sockfd, int backlog);

参数

  • sockfd : 之前 socket 函数创建的套接字描述符
  • backlog : 规定了内核应该为该套接字排队的最大连接个数。内核为监听套接字维护了两个队列。一个是未完成连接队列,主要是客户端发出 SYN 到服务器,服务器正在等待完成相应的 TCP 三路握手过程,此时套接字处于 SYN_RCVD 状态。另一个是已经完成的队列,此时套接字处于 ESTABLISHED 状态。badklog 就是两个队列之和的最大值。调用 listen 函数后,将已经完成连接队列的队头返回返回给进程;如果队列为空,进程将睡眠,直到 TCP 在该队列中放入一项才唤醒它。不要把 backlog 设置为 0,因为不同的实现对此有不同的解释。老内核一般指定为 5,但是高并发下,该值偏小不够用,新内核支持比较大的值。

返回值

  成功,返回 0。
  失败,返回 -1,并设置 error 值。
注意

  1. 该函数必须在 accept 之前调用。
  2. 当使用 socket 函数创建一个套接字时,它默认是一个主动套接字。而 listen 函数将一个未连接的套接字转换为被动套接字,指示内核应该接受该套接字的连接请求。

accept函数(服务器)

上面 listen 函数的 backlog 参数提到一个已完成的队列,accept 函数就是用于从已完成连接的队列对头返回下一个已完成队列,如果已完成队列连接为空,那么进入睡眠(套接字默认为阻塞状态)。头文件 #include <sys/socket.h>

int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

参数

  • sockefd : 监听套接字,是 socket 函数创建的套接字,接受成功后,会返回新的套接字。
  • cliaddr : 是用来返回对端客户端的地址结构信息。
  • addrlen : 是客户端套接字地址结构的长度,由该长度确定内核中存放客户端套接字的地址结构的确定字节数。

返回值

  如果返回成功(值 > 0),返回值是内核生成的一个全新的描述符,代表服务器与客户端的连接,代表已连接套接字。一个服务器通常仅创建一个监听套接字,它在服务器的生命周期内会一直存在。但是会创建多个已连接套接字,当服务器完成某一客户端的服务,已连接套接字可能会关闭。

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

注意

  如果对接收后的客户端的地址信息不感兴趣,cliaddraddrlen 参数都可以为空指针。

close函数

当处理完成后,关闭套接字描述符,并终止 TCP 连接,需要使用 close 函数,头文件 #include <unistd.h>

int close(int sockfd);

参数

  • sockfd : 是一个套接字描述符或文件描述符。

返回值

  成功返回0

  失败返回 -1,并设置 error 值。

注意:

调用 close 后,该 sockfd 不能再被使用,但是 TCP 会尝试发送已排队发送到对端的任何数据,发送完毕后发生的是正常的 TCP 连接终止序列。

描述符的引用计数

多进程的编程中,我们一般在父进程中调用 listen 后,然后通过 accept 接受客户端 ,我们调用 fork 函数创建子进程,那么现在 listenfd 和 connfd 在父子进程之间被共享(被复制),然而每个文件或者套接字都有一个引用计数,引用计数通过文件表项中维护,就是打开者着的引用该文件或套接字描述符的个数。那么我们 fork 后,这两个套接字相关联的文件表项的访问计数都变为 2。所以下一步就是,在父进程中关闭已连接套接字 connfd,而在子进程关闭监听套接字 listenfd。

那么如果我们不那么做,父进程对每个 accept 返回的已连接套接字都不调用 close,那么将会发生以下状况:

  1. 父进程最终将耗尽可用描述符。
  2. 没有一个客户端连接被终止。就算子进程关闭,只是将引用计数值由 2 变为 1,因为父进程不关闭任何已连接套接字 connfd,这将会妨碍 TCP 连接终止序列的发生,连接将一直打开。

获得套接字关联的地址

getsockname 函数获得某个套接字关联的本地协议地址,头文件为 #include<sys/socket.h>

int getsockname(int sockfd,struct sockaddr* localaddr, socklen_t* addrlen);

getpeername 函数获得某个套接字关联的外地协议地址

int getpeername(int sockfd,struct sockaddr* peeraddr, socklen_t* addrlen);

返回值:成功均返回 0,出错返回 -1。

这两个函数后两个参数都是值-结果参数,所以需要自己定义结构然后传入,结果由这两个函数进行填充。

这两个函数的作用如下:

  1. 客户端没有调用 bind,直接 connect,那么并不知道连接的本地 IP 和端口是多少,就可以调用 getsockname 来获取。
  2. 在使用端口号 0 调用 bind,是由内核选择端口号,可以调用 getsockname 返回内核赋予的端口号。
  3. getsockname 可以获取某个套接字的地址协议族。
  4. 在服务器和客户端建立连接后,getsockname 可以用于返回内核赋予该连接的本地 IP 地址,但必须是已连接套接字描述符,不能是监听套接字描述符。
  5. 当一个服务器是通过一个进程调用 exec 执行程序执行时,想要获取客户端地址信息的唯一途径便是调用 getpeername。

TCP套接字编程的注意事项

多进程服务器的信号处理

当 accept 调用返回后,我们调用 fork,在父进程关闭 connfd,由子进程来处理已连接套接字 connfd,当客户端正常终止后,服务器子进程也会终止,这时父进程会发送一个 SIGCHLD 信号。如果我们没有捕捉该信号,那么此信号将被忽略。因为父进程未处理子进程的终止,所以子进程将会处于僵死状态。

我们使用命令查看进程状态

ps -t pts/6 -o pid,ppid,tty,stat,wchan

我们可以看到 stat 状态为 Z 表示僵死状态,我们必须处理僵死状态的进程。

信号就是告知一个进程发生了什么事的通知。也叫软件中软。通常是异步发生,进程不知道什么时候会发生。

信号可以:

  1. 由一个进程发给另一个进程

  2. 由内核发给某个进程

SIGCHLD 就是内核在子进程终止时,发给它的父进程的一个信号。每个信号都有一个与其关联的处置,也叫行为。通过调用 sigaction 函数来设定一个信号的处理。

信号的处理有三种选择:

  1. 提供一个信号处理函数,只要信号发生就调用它,这种行为叫捕获信号。但是 SIGKILL 和 SIGSTOP 不能被捕获。信号处理函数由信号值单一整数参数来调用,没有返回值,原型是

    void handler(int signo);
    
  2. 可以直接把某个信号处置设置为 SIG_IGN 忽略。但是 SIGKILL 和 SIGSTOP 不能忽略。如:

    signal(SIGCHLD, SIG_IGN);
    
  3. 可以把某个信号的处置设定为 SIG_DFL 启用它的默认处置。通常是收到信号后终止进程,某些信号还在当前工作目录中生成 .core 核心映像文件。

signal函数

POSIX 处理信号函数通用方法是调用 sigaction 函数。但是该函数的参数需要我们分配并填写比较复杂。sigaction 可以提供更多的功能,详细需要参考 APUE,或者 Linux 的 man 手册。

通常简单的处理方法就是调用 signal 函数,第一个参数是信号名,第二个参数是指向函数的指针,或为 SIG_IGN 和 SIG_DFL。

函数 signal 的函数原型很复杂:

void (*signal(int signo, void (*func)(int)))(int)

为了简单,可以使用 typedef 定义:

typedef void Sigfunc(int);
Sigfunc* signal(int signo, Sigfunc* func);

一个简单的信号处理程序:

void handler(int sig)
{
    cout << "signal handler." << endl;
}

int main(int argc, char** argv)
{
    signal(SIGALRM, handler);
    alarm(1);
    return 0;
}

信号处理有以下几个特点:

  1. 一旦安装了信号处理函数就一直安装着。

  2. 在一个信号处理函数在运行时,正在被递交的信号是阻塞的。而且传递给 sigaction 函数的 sa_mask 信号集中指定的信号也被阻塞。

  3. 如果一个信号在被阻塞期间产生了一次或多次,那么该信号被解阻塞之后通常只注销了一次,因为 Unix 信号默认是不排队的。

  4. 可以使用 sigprocmask 函数选择性的阻塞或解阻塞一组信号是恶可能的。可以让程序在一段临界区代码执行期间,防止捕捉某些信号,保护此代码。

处理SIGCHILD信号

上面说过子进程没被回收会变成僵尸进程,设置僵死状态的目的是维护子进程的信息。以便父进程在某个时刻获取这些信息,包括子进程的进程 ID,终止状态,以及资源利用信息等。如果父进程终止,它的所有僵死的子进程的父进程都被置为 1(init 进程)。有 init 进程来继承并清理它们。

我们不希望僵死进程留存。因为占用内核中空间,可能导致资源耗尽。每次 fork 子进程我们都需要 wait 它们,防止变成僵死进程。因此可以建立一个 SIGCHLID 信号处理函数,在函数体中调用 wait。

void sig_child(int signo)
{
	pid_t pid;
	int stat;
	pid = wait(&stat);
	printf("child %d terminated\n", pid);
}

假如父进程阻塞于 accept 调用,子进程运行结束,sig_child 函数执行,这时候父进程捕获信号,内核就会向 accept 调用返回一个 EINTR 错误(被中断的系统调用)。如果父进程不处理,就会中止执行。

标准 C 库中的 signal 函数不会让内核自动重启被中断的系统调用,就是 sigaction 函数的 sa_flags 标志没有设置。但是另外一些新版的系统函数库对 signal 函数进行了处理,内核将重启被中断的系统调用,于是 accpet 就不会返回错误。

为了处理被中断的 accept 调用,可以做以下处理:

for (; ; )
{
	clien = sizeof(cliaddr);
    if ( (connfd) = accept(listenfd, (SA*)&cliaddr), &clilen) < 0)
    {
        if (errno == EINTR)
            continue;
        else
            printf("accept error.");
	}
}

wait 和 waitpid 函数

上面我们使用 wait 来处理已终结的子进程,waitwaitpid 函数定义如下:

#include <sys/wait.h>
pid_t wait(int* statloc);
pid_t waitpid(pid_t pid, int* statloc, int options);

返回值:均返回已终止子进程的进程 ID 号,并通过 statloc 指针返回子进程的终止状态(一个整数)。有三个宏来判断子进程的状态:1. 正常终止,2. 某个信号杀死,3. 作业控制停止等。

区别:

如果一个服务器主进程有 5 个子进程,每个子进程对应一个已连接,当客户端终止时,所有连接在同一时刻终止,子进程将信号 SIGCHLD 传递给父进程。问题在于:所有信号都在信号处理函数处理之前产生,而信号处理函数只执行一次,因为 UNIX 信号默认是不排队的,所以可能会剩余 4 个僵死进程。

现在使用 waitpid,我们在一个循环中调用 waitpid,以获取所有子进程的终止状态。但我们必须指定 WNOHANG 选项,告知 waitpid 在有尚未终止的子进程在运行时不要阻塞。

void sig_chld(int signo)
{
    pid_t pid;
    int stat;
    while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)
    {
 		printf("child %d terminated.", pid);       
    }
}

不能再循环中调用 wait 的原因是:没有办法防止 wait 在正运行的子进程尚有未终止时阻塞。

accept 返回前连接中止

另一种情况也可以导致 accept 返回一个非致命错误:三路握手完成从而连接建立,客户 TCP 却发送一个 RST(复位)。从服务器看,该连接已由 TCP 排队,等着服务器进程调用 accept 时候 RST 到达,然后服务器调用 accept,在 SVR4 中会返回一个 EPROTO 的 errno 值,而 POSIX 返回的 errno 值必须是 ECONNABORTED

POSIX 这样的理由是:在流子系统中发生某些致命的协议相关事件时,也会返回 EPROTO。要是上面 accept 也返回 EPROTO,就不知道是不是再次调用 accept 了。然而换成 ECONNABORTED 只需要再次调用 accept 即可。

SIGPIPE 信号

当一个进程已经向某个收到 RST 的套接字执行写操作时,内核会向进程发送一个 SIGPIPE 信号。该信号的默认行为是终止进程,因此我们必须捕获它,防止服务器不情愿的被终止。

不论该进程是捕捉了该信号,还是简单的忽略,写操作都将返回 EPIPE 错误。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

code_peak

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

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

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

打赏作者

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

抵扣说明:

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

余额充值