这一篇的篇幅可能有点长,但真心希望大家能够静下心来看完,相信一定会有不小的收获。那么话不多说,我们这就开始啦!!!
目录
一对一服务器中的BUG
如何实现简易的一对多服务器
实现简易一对多服务器的大体步骤
每个步骤的具体流程
1.网络初始化
2.启动监听,等待socket相关事件
多路IO复用技术之select模型
3.监听到相关事件 , 辨别是服务器socket还是客户端socket并进行处理
本地主机实现简易一对多服务器的程序实现
程序构成
结果演示
select模型的优缺点
一对一服务器中的BUG
我们先来看一段我之前写的程序——功能:实现一对一的单进程服务器
大家可以直接看该程序的while循环,来看一下该部分有哪些BUG
/*************************************************************************> File Name: nan_server.c> Author: Nan> Mail: **@qq.com> Created Time: 2023年10月20日 星期五 13时59分10秒************************************************************************/#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netdb.h>
#include<string.h>//定义一个开关,用于决定服务器是否开启,默认为开启状态
#define SERVER_SWITCH 1int main()
{//1.分别定义服务端与客户端的网络信息结构体struct sockaddr_in server_addr , client_addr;bzero(&server_addr , sizeof(server_addr));bzero(&client_addr , sizeof(client_addr));//定义一个读写缓冲区与一个存放客户端IP的缓冲区char rw_buffer[1500];char client_IP[16];bzero(rw_buffer , sizeof(rw_buffer));bzero(client_IP , sizeof(client_IP));//2.对服务端网络信息结构体进行初始化server_addr.sin_family = AF_INET;server_addr.sin_port = htons(6060);//server_addr.sin_addr.s_addr = inet_addr("192.0.0.1");server_addr.sin_addr.s_addr = inet_addr("本地主机IPV4地址");//3.创建套接字,该套接字起到监听与传输信息的作用int server_sockfd = socket(AF_INET , SOCK_STREAM , 0);if(server_sockfd == -1){perror("server socket call failed!\n");exit(-1);}//4.将IP地址与端口号绑定到监听套接字上int bind_result = bind(server_sockfd , (struct sockaddr*)&server_addr , sizeof(server_addr));if(bind_result == -1){perror("server bind call failed!\n");exit(-1);}printf("server wait connect!\n");//日志打印,可帮助理解程序执行逻辑//5.监听是否有TCP链接int backlog = 128;listen(server_sockfd , backlog);socklen_t addrlen;int client_sockfd;while(SERVER_SWITCH){printf("已进入循环!\n");//日志打印,可帮助理解程序执行逻辑addrlen = sizeof(client_addr);//6.如果接收成功,返回对应的文件描述符,并执行以下程序if( (client_sockfd = accept(server_sockfd , (struct sockaddr*)&client_addr , &addrlen)) > 0){ printf("accept call success!\n");//将网络信息结构体中的大端序IP转为字符串IP并放到读写缓冲区中inet_ntop(AF_INET , &(client_addr.sin_addr.s_addr) , client_IP , sizeof(client_IP));printf("client_IP = %s\n" , client_IP);//日志打印,帮助检测是否写入IP地址sprintf(rw_buffer , "Hello , %s , welcome connect nan_server\n" , client_IP);printf("读写缓冲区中内容为 %s\n" , rw_buffer);//日志打印,帮助检测是否写入要发送的数据//将读写缓冲区中的内容发送到服务端的套接字中,由套接字向客户端发送数据send(client_sockfd , rw_buffer , sizeof(rw_buffer) , MSG_NOSIGNAL);//清空读写缓冲区与存放IP的缓冲区,以供下一次使用bzero(rw_buffer , sizeof(rw_buffer)); bzero(client_IP , sizeof(client_IP));//读取客户端发来的数据recv(client_sockfd , rw_buffer , sizeof(rw_buffer) , 0);printf("client_message : %s\n" , rw_buffer);bzero(rw_buffer , sizeof(rw_buffer));}else if(client_sockfd == -1){perror("accept call failed!\n");continue;}}close(server_sockfd);
}
如果大家看不出来的话,再给大家一点提示:这些BUG都和阻塞与socket缓冲区有关
现在来公布一下答案吧,这个服务器的BUG在于——
- 如果一直没有客户端向服务器发起TCP链接请求,socket缓冲区中没有表示链接请求的标志数据SYN可读取,服务器的程序就会一直阻塞在accept函数那里,无法执行其他程序,整个服务器一直处于阻塞等待状态
- recv函数的调用,由于服务器使用阻塞状态的recv函数,如果客户端迟迟不发送信息,socket缓冲区就会一直为空,整个服务器就会一直阻塞等待该客户端发送数据,无法处理其他客户端的链接请求
那么我们要怎么处理这些BUG呢?
其实看完上面的BUG,大家或多或少都能明白这两个BUG的本质——其实无非就是,由于socket缓冲区中没有数据,可accept函数和recv函数却一直在等待数据的读取,导致服务器一直阻塞等待数据的读取
要想解决这两个BUG,其实也很简单——我们需要一个类似信号的功能,当socket缓冲区中有数据,触发相关事件,需要服务器进行处理时,我们再去进行处理,socket缓冲区中没有数据时,服务器不要一直阻塞等待数据
如何实现简易的一对多服务器
实现简易一对多服务器的大体步骤
- 网络初始化
- 启动监听,等待socket相关事件(也就是查看socket缓冲区中是否有数据需要处理)
- 监听到相关事件,辨别是服务器socket还是客户端socket,并进行处理
PS:处理socket相关事件这里要分两类——
- 服务器套接字接收到客户端的TCP链接请求,调用accept函数
- 服务器中为该客户端创建的套接字接收到客户端发来的信息,调用recv函数
每个步骤的具体流程
1.网络初始化
网络初始化这个步骤就不多做介绍了,就是简单的初始化网络信息结构体、创建服务器套接字、绑定套接字等操作
不会的同学可以去看一下我之前写的这篇博客,相关函数与使用方法都在里面:【Linux】如何在本地主机实现简易的一对一服务器(附图解与代码实现)http://t.csdnimg.cn/thQS8
2.启动监听,等待socket相关事件
既然是要实现一对多的服务器,就代表着我们要为每个链接的客户端分别创建对应的套接字,同时这也就意味着我们自然也要去监听这些套接字
多路IO复用技术这么名字听起来很高大上,其实本质上就是一个IO事件监听技术,也就是一次性可以监听多个socket,来判断这些socket中是否有数据需要处理并反馈给服务器。所以在这个过程中我们也就要用到该技术中的一种——select
接下来我们来讲解一下select的原理实现与相关函数
多路IO复用技术之select模型
原理实现
select中有一个集合,叫做监听集合,我们可以将需要监听的套接字放入套接字文件描述符表中,由该集合负责帮我们监听该文件描述符表中这些套接字文件描述符对应的这些套接字的缓冲区中是否有数据需要处理
这个监听集合的大小为1024(固定大小,不可改),但需要注意的是,虽然这个集合的大小为1024,但实际能帮我们监听的客户端套接字只有1020个,因为前1-3个分别用于监听标准输入、标准输出和标准出错,第四个用于存放服务器套接字
可能只用文字描述过于抽象了,大家可以看下面的这个图来帮助理解
通过这个监听集合,我们就可以实现对多个socket的同时监听
这时候可能就有同学好奇了,监听?他咋监听啊?是什么高级手段吗?
其实这个监听真的是一种很朴实无华的方法,就是遍历,一次次的遍历,当监听集合完成一遍遍历,发现有套接字处于就绪状态,也就是某些套接字的缓冲区中有数据需要处理时,他就会传出一个就绪码(处于就绪状态的套接字数量)和一个就绪集合(就绪的套接字的位码置1,未就绪的套接字的位码置0)
还是画个图来帮助大家理解
以上就是select模型的相关原理了,接下来我们来讲一讲相关函数
以下函数的头文件都是#include<sys/select.h>
介绍一下一会会用到的变量:
- fd_set set ; //创建监听集合
- int sockfd ; //套接字文件描述符
- int max_fd ; //套接字文件描述符表中的描述符个数
- struct timeval *timeout ; //时间结构体,在这里表示工作模式——1.阻塞、2.非阻塞、3.定时阻塞(非阻塞与定时阻塞需要设置该结构体)
PS : timeout中有两个成员,一个表示秒(timeout.tv_sec),一个表示微秒(timeout.tv_usec)
struct timeval
{
__time_t tv_sec; /* Seconds. */
__suseconds_t tv_usec; /* Microseconds. */
};
- timeout = NULL 就表示阻塞监听
- timeout.tv_sec = 0 、timeout.tv_sec = 0 就表示非阻塞监听
- timeout.tv_sec = 4、timeout.tv_usec = 30 就表示阻塞4秒30微秒,之后不阻塞
函数 | 功能 | 返回值 |
FD_ZERO(&set); | 初始化监听集合,将所有位的位码都初始化为0 | 因为它们只是对文件描述符集合进行操作,而不是返回任何值,所以他们的返回值都是void |
FD_SET(sockfd , &set); | 将set集合中与sockfd对应位的位码设置为1 | 因为它们只是对文件描述符集合进行操作,而不是返回任何值,所以他们的返回值都是void |
FD_CLR(sockfd , &set); | 将set集合中与sockfd对应位的位码设置为0 | 因为它们只是对文件描述符集合进行操作,而不是返回任何值,所以他们的返回值都是void |
FD_ISSET(sockfd , &set); | 获取set集合中与sockfd对应位的位码 | 0或1 |
int select(max_fd, 是否监听读事件 , 是否监听写事件 , 是否监听错误事件 , timeout); | 监听我们要求的文件描述符的状态变化情况,并通过返回值告知(PS:想监听对应时间就传入&set,不想就传NULL) | 返回处于就绪状态的套接字数量 |
3.监听到相关事件 , 辨别是服务器socket还是客户端socket并进行处理
上面的就绪集合还需要我们自己去遍历,从而找到哪些套接字需要进行数据处理,并辨别是服务器套接字还是客户端套接字
如果是服务器套接字,就说明是有客户端向服务器发送了TCP链接请求,有以下步骤需要执行:
- 调用accept函数进行链接并获取与该客户端对应的套接字文件描述符
- 将其放入套接字文件描述符存放数组(由于该服务器为单进程,所以我们需要建立一个数组来存放这些客户端套接字文件描述符)(这个地方不太懂的话别着急,结合代码来看一定会让你豁然开朗)
- 将该套接字文件描述符放入套接字文件描述符表中,来让监听集合对该套接字进行监听
如果是客户端套接字,就说明是客户端向服务器发送了数据,有以下步骤需要执行:
- 调用recv函数读取套接字缓冲区中的数据
- 根据客户端发来的数据进行相应处理
具体过程如下图所示:
在了解了实现一对多服务器的具体流程后,我们来看一下具体如何使用select模型用程序实现一对多服务器
本地主机实现简易一对多服务器的程序实现
程序构成
该服务器与客户端由以下几个程序共同组成:
- func_2th_parcel.h:定义二次包裹的函数名
- func_2th_parcel.c:对网络初始化相关的函数进行二次包裹
- select_server.c:使用select模型的服务器程序
- client.c:客户端程序
/*************************************************************************> File Name: func_2th_parcel.h> Author: Nan> Mail: **@qq.com> Created Time: 2023年10月18日 星期三 18时32分22秒************************************************************************/#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <signal.h>
#include <pthread.h>
#include <fcntl.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/mman.h>
#include <time.h>
#include <ctype.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <poll.h>
#include <sys/epoll.h>//socket函数的二次包裹
int SOCKET(int domain , int type , int protocol);//bind函数的二次包裹
int BIND(int sockfd , const struct sockaddr* addr , socklen_t addrlen);//listen函数的二次包裹
int LISTEN(int sockfd , int backlog);//send函数的二次包裹
ssize_t SEND(int sockfd , const void* buf , size_t len , int flags);//recv函数的二次包裹
ssize_t RECV(int sockfd , void* buf , size_t len , int flags);//connect函数的二次包裹
int CONNECT(int sockfd , const struct sockaddr* addr , socklen_t addrlen);//accept函数的二次包裹
int ACCEPT(int sockfd , struct sockaddr* addr , socklen_t addrlen);//网络初始化函数
int SOCKET_NET_CREATE(const char* ip , int port);//服务端与客户端建立连接并返回客户端套接字文件描述符
int SERVER_ACCEPTING(int server_fd);
/*************************************************************************> File Name: func_2th_parcel.c> Author: Nan> Mail: **@qq.com> Created Time: 2023年10月18日 星期三 18时32分42秒************************************************************************/#include <func_2th_parcel.h>int SOCKET(int domain , int type , int protocol){int return_value;if((return_value = socket(domain , type , protocol)) == -1){perror("socket call failed!\n");return return_value;}return return_value;
}int BIND(int sockfd , const struct sockaddr* addr , socklen_t addrlen){int return_value; if((return_value = bind(sockfd , addr , addrlen)) == -1){perror("bind call failed!\n");return return_value;} return return_value;
}int LISTEN(int sockfd , int backlog){int return_value; if((return_value = listen(sockfd , backlog)) == -1){perror("listen call failed!\n");return return_value;} return return_value;
}ssize_t SEND(int sockfd , const void* buf , size_t len , int flags){ssize_t return_value;if((return_value = send(sockfd , buf , len , flags)) == -1){perror("send call failed!\n");return return_value;}return return_value;
}ssize_t RECV(int sockfd , void* buf , size_t len , int flags){ssize_t return_value; if((return_value = recv(sockfd , buf , len , flags)) == -1){perror("recv call failed!\n");return return_value;} return return_value;
}int CONNECT(int sockfd , const struct sockaddr* addr , socklen_t addrlen){int return_value; if((return_value = connect(sockfd , addr , addrlen)) == -1){perror("connect call failed!\n");return return_value;} return return_value;
}int ACCEPT(int sockfd , struct sockaddr* addr , socklen_t addrlen){int return_value; if((return_value = accept(sockfd , addr , &addrlen)) == -1){perror("accept call failed!\n");return return_value;} return return_value;
}int SOCKET_NET_CREATE(const char* ip , int port){int sockfd;struct sockaddr_in addr;addr.sin_family = AF_INET;addr.sin_port = htons(port);inet_pton(AF_INET , ip , &addr.sin_addr.s_addr);sockfd = SOCKET(AF_INET , SOCK_STREAM , 0);BIND(sockfd , (struct sockaddr*)&addr , sizeof(addr));LISTEN(sockfd , 128);return sockfd;
}int SERVER_ACCEPTING(int server_fd)
{int client_sockfd;struct sockaddr_in client_addr;char client_ip[16];char buffer[1500];bzero(buffer , sizeof(buffer));bzero(&client_addr , sizeof(client_addr));socklen_t addrlen = sizeof(client_addr);client_sockfd = ACCEPT(server_fd , (struct sockaddr*)&client_addr , addrlen);bzero(client_ip , 16);//将客户端的IP地址转成CPU可以识别的序列并存储到client_ip数组中inet_ntop(AF_INET , &client_addr.sin_addr.s_addr , client_ip , 16);sprintf(buffer , "Hi , %s welcome tcp test server service..\n" , client_ip);printf("client %s , %d , connection success , client sockfd is %d\n" , client_ip , ntohs(client_addr.sin_port) , client_sockfd);SEND(client_sockfd , buffer , strlen(buffer) , MSG_NOSIGNAL);return client_sockfd;
}
/*************************************************************************> File Name: select_server.c> Author: Nan> Mail: **@qq.com> Created Time: 2023年10月25日 星期三 18时53分30秒************************************************************************/#include <func_2th_parcel.h>int main(void)
{//一、进行网络初始化int server_sockfd;//服务器套接字文件描述符int max_fd;//套接字文件描述符表中的描述符个数int client_sockfd_array[1020];//存放客户端套接字文件描述符的数组 int client_sockfd;//客户端套接字文件描述符int ready_num = 0;//获取处于就绪状态的套接字数目char rw_buffer[1500];//读写缓冲区int flag;int recv_len = 0;//客户端发来的数据长度memset(client_sockfd_array , -1 , sizeof(client_sockfd_array));//将套接字数组每一位都置为-1,方便后面查找就绪套接字bzero(rw_buffer , sizeof(rw_buffer));fd_set listen_set , ready_set;//监听集合,就绪集合server_sockfd = SOCKET_NET_CREATE("192.168.79.128" , 6060);//初始化服务器套接字max_fd = server_sockfd;//初始化最大套接字数目//初始化监听集合,将server_sockfd设置为监听套接字FD_ZERO(&listen_set);FD_SET(server_sockfd , &listen_set);printf("select_server wait TCP connect\n");//二、启动监听,等待socket相关事件while(1){ready_set = listen_set;//阻塞等待socket相关事件if((ready_num = select(max_fd + 1 , &ready_set , NULL , NULL , NULL)) == -1){perror("select call failed\n");exit(0);}//三、监听到相关事件 , 辨别是服务器socket还是客户端socket并进行处理while(ready_num){//辨别就绪,如果是服务端套接字就绪if(FD_ISSET(server_sockfd , &ready_set)){client_sockfd = SERVER_ACCEPTING(server_sockfd);//与客户端建立TCP链接FD_SET(client_sockfd , &listen_set);//将该套接字放入监听集合中//如果max_fd小于客户端套接字返回的描述符,说明这个新的客户端套接字放到了最后一位,max_fd需要加1if(max_fd < client_sockfd){max_fd = max_fd + 1;}for(int i = 0 ; i < 1020 ; i++){//将该客户端套接字,放到数组中有空缺的地方if(client_sockfd_array[i] == -1){client_sockfd_array[i] = client_sockfd;break;}}//将就绪集合中服务器套接字这一位的位码置为0,因为如果ready_num > 1,不做该处理服务器会一直认为是客户端发送了TCP链接请求,从而导致错误处理FD_CLR(server_sockfd , &ready_set);}//如果是客户端套接字就绪else{for(int i = 0 ; i < 1020 ; i++){//检测存放的客户端套接字是否处于就绪状态if(client_sockfd_array[i] != -1){//如果该套接字处于就绪状态if(FD_ISSET(client_sockfd_array[i] , &ready_set)){recv_len = RECV(client_sockfd_array[i] , rw_buffer , sizeof(rw_buffer) , 0);//获取数据长度printf("客户端%d 发来数据 : %s , 现在进行处理\n" , client_sockfd_array[i] , rw_buffer);flag = 0;}//如果recv_len = 0,就说明与客户端套接字对应的客户端退出了,将对应客户端套接字移出套接字存储数组与监听集合if(recv_len == 0){printf("客户端%d 已下线\n" , client_sockfd_array[i]); FD_CLR(client_sockfd_array[i] , &ready_set);client_sockfd_array[i] = -1;break;}//进行业务处理:小写字母转大写字母while(recv_len > flag){rw_buffer[flag] = toupper(rw_buffer[flag]);flag++;}SEND(client_sockfd_array[i] , rw_buffer , recv_len , MSG_NOSIGNAL);printf("已向客户端%d 发送处理后的数据 : %s\n" , client_sockfd_array[i] , rw_buffer);bzero(rw_buffer , sizeof(rw_buffer));recv_len = 0;FD_CLR(client_sockfd_array[i] , &ready_set);break;}}}ready_num--;}}close(server_sockfd);printf("server shutdown\n");return 0;
}
/*************************************************************************> File Name: client.c> Author: Nan> Mail: **@qq.com> Created Time: 2023年10月19日 星期四 18时29分12秒************************************************************************/#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <signal.h>
#include <pthread.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <time.h>//服务器实现大小写转换业务int main()
{//1.定义网络信息结构体与读写缓冲区并初始化struct sockaddr_in dest_addr;char buffer[1500];bzero(&dest_addr , sizeof(dest_addr));bzero(buffer , sizeof(buffer));dest_addr.sin_family = AF_INET;dest_addr.sin_port = htons(6060);//字符串ip转大端序列inet_pton(AF_INET , "192.168.79.128" , &dest_addr.sin_addr.s_addr);int sockfd = socket(AF_INET , SOCK_STREAM , 0);int i;//2.判断连接是否成功if((connect(sockfd , (struct sockaddr*) &dest_addr , sizeof(dest_addr))) == -1){perror("connect failed!\n");exit(0);}recv(sockfd , buffer , sizeof(buffer) , 0);printf("%s" , buffer);bzero(buffer , sizeof(buffer));//3.循环读取终端输入的数据while( (fgets(buffer , sizeof(buffer) , stdin) ) != NULL){i = strlen(buffer);buffer[i-1] = '\0';//向服务端发送消息send(sockfd , buffer , strlen(buffer) , MSG_NOSIGNAL);//接收服务端发来的消息recv(sockfd , buffer , sizeof(buffer) , 0);//打印服务端发来的信息printf("response : %s\n" , buffer);//清空读写缓冲区,以便下一次放入数据bzero(buffer , sizeof(buffer));}//4.关闭套接字,断开连接close(sockfd);return 0;
}
结果演示
在了解了如何用程序实现使用select模型的一对多服务器后,我们来了解一下select模型的优缺点
select模型的优缺点
优点:
- 由于select模型出现的非常早,所以他的兼容性很强,便于跨平台,各个平台语言都支持
- select可以实现微妙级别的定时阻塞,可满足某些对时间精度要求较高的场景
- 适宜于局域网开发
缺点:
- 监听的数量太少,最多只能同时监听1024个套接字,不适宜于广域网开发
- select的监听是通过一次次的遍历实现的,非常消耗CPU,会导致服务器吞吐能力会非常差
- 随着select的持续使用,会产生大量的拷贝开销和挂载开销(注释①)
- select没有对监听集合进行传入传出分离,用户需要自己定义传入集合(监听集合)和传出集合(就绪集合)
- select只传出了处于就绪状态的套接字数量,而没有告诉用户是哪些套接字处于就绪状态,需要用户自己一个一个的去遍历查找
- select模型只能监听读事件、写事件、异常事件,但其实socket的相关事件是有很多的,选择性比较少
- select模型只能批量监听。以读事件举例,这个函数就导致select模型要么监听所有套接字的读事件,要么完全不监听所有套接字的读事件,无法灵活地为每个套接字监听不同的事件(不明白这个地方的可以看下前面讲解的select函数的构成)
注释①:
第3个缺点需要为大家讲解一下原因
我们知道,select模型中的监听集合可以实现对套接字的监听,我们也讲过所谓的监听其实就是通过遍历实现的,但遍历这件事其实不是监听集合去做的,接下来为大家讲解一下具体流程,如下所示:
- 我们在用户层定义了一个变量 : fd_set set ;
- 系统会将这个用户层的监听集合拷贝到内核层(这就是拷贝开销的第一部分)
- 系统会将内核层的该监听集合中监听的套接字放入IO设备等待队列(这就是挂载开销的第一部分),由IO设备等待队列来进行一次又一次的遍历来判断那些套接字中有数据需要处理
- 当IO设备等待队列发现有套接字处于就绪状态时,会传出就绪集合到内核层(挂载开销的第二部分)
- 系统通过select模型将该就绪集合由内核层拷贝到用户层(拷贝开销的第二部分),供用户使用
如果看文字看不明白的话,大家可以看一下下面的图
有人一看了就会说,这不也没啥吗,不就拷贝一下挂载一下嘛,有啥大的开销啊?
这样看上去,可能开销确实没什么,但要注意的是,每当有新的套接字放入监听集合中时,系统是不会将新的套接字拷贝到内核层并放入IO设备等待队列,而是将整个新的监听集合全部拷贝到内核层,并将监听的套接字一个一个重新挂载到IO设备等待队列,举个例子
原先这个监听集合里监听100个套接字,后面又加了6个新的套接字来让监听集合监听,系统会直接把这整个新的监听集合拷贝到内核层,然后把这106个套接字重新挂载到IO设备等待队列,如果有相同的直接覆盖掉。一旦用的轮数越多,监听的套接字个数越多,这个开销的增长就不好估计了
所以,这就是为什么select模型会有上面所述的第3个缺点
以上就是本篇博客的全部内容了,大家有什么地方没有看懂的话,可以在评论区留言给我,咱要力所能及的话就帮大家解答解答
今天的学习记录到此结束啦,咱们下篇文章见,ByeBye!