首页 > 其他 > 详细

TCP/IP 网络编程 (三)

时间:2015-03-16 23:16:07      阅读:472      评论:0      收藏:0      [点我收藏+]

服务器端未处理高并发请求通常采用如下方式:

  • 多进程:通过创建多个进程提供服务
  • 多路复用:通过捆绑并统一管理 I/O 对象提供服务
  • 多线程:通过生成和客户端等量的线程提供服务

多进程服务器端

#include <unistd.h>

pid_t fork(); // 成功返回进程 ID, 失败返回-1

fork函数将创建调用的函数副本。子进程将使用新的内存空间复制当前函数的环境。

  • 父进程:函数返回子进程ID
  • 子进程:函数返回 0

可以理解为调用该函数之后将存在两个pid_t,分别存在父子进程中,因此它们将会根据不同的函数值执行相应的进程代码。

在调用函数前的所有变化都会在子进程中保持一致。调用函数之后的变化将不会影响彼此,因为它们完全不相干,虽然可以通过进程间通信交换信息。

僵尸(zombie)进程

exit函数传递的参数值和return语句返回的值会传递给操作系统。操作系统不会销毁子进程,而是将这些值传递给产生该子进程的父进程。处在这种状态下的进程就是僵尸进程。

exit(0) 表示程序正常, exit(1)/exit(-1)表示程序异常退出;
exit() 结束当前进程/当前程序/,在整个程序中,只要调用 exit ,就结束.

exit(0):正常运行程序并退出程序;
exit(1):非正常运行导致退出程序;
return():返回函数,若在main主函数中,则会退出函数并返回一值,可以写为return(0),或return 0

exit表示进程终结,不管是在哪个函数调用中,即使还存在被调函数。
return表示函数返回,如果是在主函数中意味着进程的终结;如果不是在主函数中那么会返回到上一层函数调用。

详细说:

  1. return返回函数值,是关键字;exit是一个函数。
  2. return是语言级别的,它表示了调用堆栈的返回;而exit是系统调用级别的,它表示了一个进程的结束。
  3. return是函数的退出(返回);exit是进程的退出。
  4. returnC语言提供的,exit是操作系统提供的(或者函数库中给出的)。
  5. return用于结束一个函数的执行,将函数的执行信息传出给其他调用函数使用;exit函数是退出应用程序,删除进程使用的内存空间,并将应用程序的一个状态返回给OS,这个状态标识了应用程序的一些运行信息,这个信息和机器和操作系统有关,一般是 0 为正常退出,非0 为非正常退出。
  6. 非主函数中调用returnexit效果很明显,但是在main函数中调用returnexit的现象就很模糊,多数情况下现象都是一致的。

销毁僵尸进程

利用 wait 函数

#include <sys/wait.h>

pid_t wait(int * statloc); // 成功返回终止的子进程 ID,失败返回 -1

当有子进程终止时,子进程终止时传递的返回值将保存在该函数参数所指内存空间,参数指向的单元中还包含其他信息,需要使用宏进行分离。

通过调用该函数之前终止的子进程相关信息将保存在参数变量中,同时,相关子进程被完全销毁。调用是如果没有已终止的进程,那么程序将会阻塞直到有子进程终止。

  • WIFEXITED: 子进程正常终止时返回真
  • WEXITSTATUS: 返回子进程的返回值

也就是说,向wait函数传递变量status的地址时,调用wait函数后应编写如下代码:

if(WIFWXITED(status))
{
    puts("Normal termination!");
    printf("Child pass num: %d", WEXITSTATUS(status));
}

利用 waitpid 函数

#include <sys/wait.h>

pid_t waitpid(pid_t pid, int * statloc, int options); // 同上

~ pid: 等待终止的目标子进程的ID, 若传递-1,则等效于 wait, 可以等待任意子进程终止
~ statloc: 同上
~ options: 传递常量 WNOHANG, 即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出

代码示例:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
    int status;
    pid_t pid = fork();

    if(pid == 0)
    {
        sleep(15);
        return 34;
    }
    else
    {
        while(!waitpid(-1, &status, WNOHANG)) //如果没有进程终止,就循环等待
        {
            sleep(3);
            puts("sleep 3 sec.");
        }

        if(WIFEXITED(status))
            printf("Child send %d \n", WEXITSTATUS(status));
    }
    return 0;
}

信号处理

子进程终止的识别主题是操作系统,因此在子进程终止的时候由操作系统将这些信息通知忙碌的父进程,父进程停下手上的工作处理相关事宜。

为此,我们引入信号处理。此处的“信号”是指在特定时间发生时由操作系统向进程发送的消息。为了响应该消息,执行与消息相关的自定义操作的过程称为“处理”或“信号处理”。

信号和 signal 函数

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int); // 在产生信号时调用,返回之前注册的函数指针

发生第一个参数代表的情况时,调用第二个参数所指的情况。

第一个参数可能对应的常数:

  • SIGALRM: 已到通过alarm函数注册的时间
  • SIGINT: 输入CTRL + C
  • SIGCHLD: 子进程终止

代码示例:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void timeout(int sig) // 信号处理器
{
    if(sig == SIGALRM)
        puts("Time out!");
    alarm(2);
}

void keycontrol(int sig)
{
    if(sig == SIGINT)
        puts("Ctrl + C Pressed");
}

int main()
{
    int i;
    signal(SIGALRM, timeout); // 注册处理函数
    signal(SIGINT, keycontrol);
    alarm(2); // unistd.h

    for(i=0; i < 3; i++)
    {
        puts("wait ... ");
        sleep(100);
    }
    return 0;
}

利用 sigaction 处理信号

#include <signal.h>

int sigaction(int signo, const struct sigaction * act, struct sigaction * oldact); // 成功返回0, 失败返回-1

~ signo: 传递信号信息
~ act: 对应于第一个参数的信号处理函数信息
~ oldact: 通过此参数获取之前注册注册的信号处理函数指针,不需要则传递0
struct sigaction
{
    void (*sa_handler)(int); // 信号处理的函数指针
    sigset_s sa_mask; // 用于指定信号相关的选项和特性
    int sa_flags;
}

使用上和之前的signal没有明显区别。

struct sigaction act;
act.sa_handler=timeout;
sigemptyset(&act.sa_mask);
act.sa_flags=0;
sigaction(SIGALRM, &act, 0);
...

利用信号处理技术消灭僵尸进程

代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void read_childproc(int sig)
{
    int status;
    pid_t id = waitpid(-1, &status, WNOHANG);
    if(WIFEXITED(status))
    {
        printf("Remove proc id : %d \n", id);
        printf("Child send : %d \n", WEXITSTATUS(status));
    }
}

int main(int argc, char * argv[])
{
    pid_t pid;
    struct sigaction act;
    act.sa_handler = read_childproc;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;
    sigaction(SIGCHLD, &act, 0);

    pid = fork();

    if(pid == 0)
    {
        puts("Hi! I‘am child proc");
        sleep(10);
        return 12;
    }
    else
    {
        printf("Child proc id : %d \n", pid);
        pid = fork();
        if(pid == 0)
        {
            puts("Hi! I‘am child proc");
            sleep(10);
            exit(24);
        }
        else
        {
            int i;
            printf("Child proc id : %d \n", pid);
            for(i=0; i < 5; i++)
            {
                puts("wait ... ");
                sleep(5);
            }
        }
    }
    return 0;
}

为了等待SIGCHLD信号,父进程共暂停5次,每次间隔5秒。发生信号时,父进程将被唤醒,因此实际暂停不到25秒。

基于多任务的并发服务器

echo_mpserver.c

通过 fork 函数复制文件描述符

上述示例中父进程将两个文件描述符(服务器套接字和客户端套接字)复制给子进程。

实际上只是复制了文件描述符,没有复制套接字。因为套接字并非进程所有—严格来说,套接字属于操作系统—只是进程拥有代表相应套接字的文件描述符。

技术分享

如上图所示,只有两个文件描述符都终止后才能销毁套接字。即使子进程销毁了与客户端的套接字文件描述符也不能完全销毁套接字。因此,调用fork函数之后,要将无关的套接字文件描述符关掉。如下图所示:

技术分享

分割 TCP 的 I/O 程序

分割模型如下:

技术分享

在客户端中将读写分离,这样就不用再写之前等待读操作的完成。

echo_mpclient.c

进程间通信

通过管道实现进程间通信

管道属于操作系统资源,因此,两个进程通过操作系统提供的内存空间进行通信。下面是创建管道的函数:

#include <unistd.h>

int pipe(int filedes[2]) // 成功返回0, 失败返回-1

~ filedes[0]: 通过管道接收数据时使用的文件描述符
~ filedes[1]: 通过管道发送数据时使用的文件描述符

示例代码:

pipe1.c

上述代码的示例如下:

技术分享

通过管道进行进程间的双向通信

模型如下:

技术分享

这里有一个问题,“向管道中传递数据的时候,先读的进程会把数据读走”。简而言之,数据进入到管道中就成为无主数据,不管谁先读取数据都能够将数据读走。

综上所述,使用一个管道实现双向通道并非易事,因为需要预测并控制通信流,这是不现实的。因此可以使用两个管道实现双向通信,模型如下:

技术分享

pipe3.c

运用进程间通信

代码示例:

echo_storeserv.c

上述代码涉及到的模型如下:

技术分享

技术分享

I/O 复用

这里我们讨论使用 I/O 复用解决每个客户端请求都创建进程的资源消耗弊端。

运用select函数是最具有代表性的实现复用服务器端方法。使用该函数的时候可以将多个文件描述符集中到一起进行监视。

  • 是否存在套接字接收数据?
  • 无需阻塞传输数据的套接字有哪些?
  • 哪些套接字发生了异常?

我们将监视项称为事件。发生了监视项对应的情况时,称“发生了事件”。

select函数很难使用,但是为了实现I/O复用服务器端,我们应该掌握该函数,并运用到套接字编程中。认为“select函数时I/O复用的全部内容”并不为过。

技术分享

设置文件描述符

将要监视的套接字集合在一起,集中式也要按照监视项(接收、传输、异常)进行区分。使用fd_set数组变量执行此项操作。

在数组中注册或者更改值的操作应该由下列宏完成:

FD_ZERO(fd_set * fdset): 所有位初始化为0
FD_SET(int fd, fd_set * fdset): 注册文件描述符fd 的信息
FD_CLR(int fd, fd_set * fdset): 清楚文件描述符的信息
FD_ISSET(int fd, fd_set * fdset): 是否监视

上述定义可在下面直观看到效果:

技术分享

设置监视范围及超时

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfd, fd_set * readset, fd_set * writeset, fd_set * excepyset, const struct timeval * timeout); // 成功时返回大于0的值, 失败返回-1

~ maxfd: 监视对象文件描述符的数量
~ readset: 是否存在待读取数据
~ writeset: 是否可传输无阻塞数据
~ exceptset: 是否发生异常
~ timeout: 调用该函数之后,为防止陷入无限阻塞的状态,传递超时信息
返回值: 发生错误返回-1, 超时返回0,因发生关注的事件返回时,返回大于0的值,该值是发生事件的文件描述符数

“文件描述符的监视范围?”

函数要求通过第一个参数传递监视对象文件描述符的数量。因此需要得到注册在fdset变量中的文件描述符数。但每次新建文件描述符时,其值都会加1,故将最大的文件描述符加1再传递到函数即可。加1是因为文件描述符的值从0开始。

“如何设置超时时间?”

struct timeval
{
    long tv_sec; // seconds
    long tv_usec; // microseconds
}

函数只有在监视的文件描述符发生变化时才返回,如果未发生变化就会进入阻塞状态。指定超时时间就是为了防止这种情况的发生。即使文件描述符未发生变化,只要到了指定时间函数也会返回。当然,返回值是0。如果不设置超时,传递NULL即可。

调用函数后查看结果

如果函数返回值大于0,说明相应数量的文件描述符发生变化。

文件描述符变化是指监视的文件描述符发生了相应的监视事件

那么,如何得知哪些文件描述符发生了变化呢?

向函数传递的第二个到第四个参数传递的fd_set变量将发生如下图所示的变化:

技术分享

函数调用之后,向其传递的fd_set变量将发生变化,原来为1的所有位将变成0,但发生变化的位除外。换句话说就是,调用之后,发生变化的文件描述符的位将为1。

select.c

实现I/O复用服务器端

echo_selectserv.c

多种 I/O 函数

Linux 中的 send & recv

#include <sys/socket.h>

ssize_t send(int sockfd, const void * buf, size_t nbytes, int flags); // 成功返回发送的字节数, 失败返回-1
#include <sys/socket.h>

ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);

MSG_OOB: 发送紧急数据

带外数据概念实际上是向接收端传送三个不同的信息:

  1. 发送端进入紧急模式这个事实。接收进程得以通知这个事实的手段不外乎SIGURG信号或select调用。本通知在发送进程发送带外字节后由发送端TCP立即发送,即使往接收端的任何数据发送因流量控制而停止了,TCP仍然发送本通知。本通知可能导致接收端进入某种特殊处理模式,以处理接收的任何后继数据。
  2. 带外字节的位置,也就是它相对于来自发送端的其余数据的发送位置:带外标记。
  3. 带外字节的实际值。既然TCP是一个不解释应用进程所发送数据的字节流协议,带外字节就可以是任何8位值。

对于TCP的紧急模式,我们可以认为URG标志时通知(信息1),紧急指针是带外标记(信息2),数据字节是其本身(信息3)。

与这个带外数据概念相关的问题有:

  • 每个连接只有一个TCP紧急指针;
  • 每个连接只有一个带外标记;
  • 每个连接只有一个单字节的带外缓冲区(该缓冲区只有在数据非在线读入时才需考虑)。如果带外数据时在线读入的,那么当心的带外数据到达时,先前的带外字节字节并未丢失,不过他们的标记却因此被新的标记取代而丢失了。

带外数据的一个常见用途体现在rlogin程序中。当客户中断运行在服务器主机上的程序时,服务器需要告知客户丢弃所有已在服务器排队的输出,因为已经排队等着从服务器发送到客户的输出最多有一个窗口的大小。服务器向客户发送一个特殊字节,告知后者清刷所有这些输出(在客户看来是输入),这个特殊字节就作为带外数据发送。客户收到由带外数据引发的SIGURG信号后,就从套接字中读入直到碰到带外数据。客户收到由带外数据引发的SIGURG信号后,就从套接字中读入直到碰到带外标记,并丢弃到标记之前的所有数据。这种情形下即使服务器相继地快速发送多个带外字节,客户也不受影响,因为客户只是读到最后一个标记为止,并丢弃所有读入的数据

总之,带外数据是否有用取决于应用程序使用它的目的。如果目的是告知对端丢弃直到标记处得普通数据,那么丢失一个中间带外字节及其相应的标记不会有什么不良后果。但是如果不丢失带外字节本身很重要,那么必须在线收到这些数据。另外,作为带外数据发送的数据字节应该区别于普通数据,因为当前新的标记到达时,中间的标记将被覆写,从而事实上把带外字节混杂在普通数据之中。举例来说,telnet在客户和服务器之间普通的数据流中发送telnet自己的命令,手段是把值为255的一个字节作为telnet命令的前缀字节。(值为255的单个字节作为数据发送需要2个相继地值为255的字节。)这么做使得telnet能够区分其命令和普通用户数据,不过要求客户进程和服务器进程处理每个数据字节以寻找命令。

除紧急指针(URG指针)指向的一个字节外,数据接收方将通过调用常用输入函数读取剩余部分。

检查输入缓冲

设置MSG_PEEK选项并调用recv函数之后,即使读取了输入缓冲的数据也不会删除。因此,该选项通常与MSG_DONTWAIT合作,用于调用非阻塞方式验证待读取数据存在与否。

readv & writev 函数

对数据进行整合传输及发送的函数

通过writev函数可以将分散保存在多个缓冲中的数据一并发送。适当使用这两个函数可以减少I/O函数的调用次数。

#include <sys/uio.h>

ssize_t writev(int filedes, const struct iovec * iov, int iovcnt); 

~ filedes: 数据传输对象的套接字文件描述符
~ iov: iovec结构体数组的地址,结构体中包含待发送数据的位置和大小信息
~ iovcnt: 第二个参数的数组长度
struct iovec
{
    void * iov_base; // 缓冲地址
    size_t iov_len; // 缓冲大小
}

关系模型如下:

技术分享

writev.c
#include <sys/uio.h>

ssize_t readv(int filedes, const struct iovec * iov, int iovcnt);
readv.c

TCP/IP 网络编程 (三)

原文:http://blog.csdn.net/yapian8/article/details/44313419

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!