LwIP使用select,close socket资源释放不完全问题

这篇文章本应该在4月就写好的,但是博客评论系统一直没有搭建好,走了很多弯路,现在好了,delay这么久,终于要要补过来了。自建博客:金宝的博客

该文章完全原创,除通用、广泛的知识点外,均为个人总结,如需转载还望备注出处,同时如有错误还请指出,虚心接受。

一、简介

1. 题外话

  以这篇文章为第一篇技术文章,一是萌生写博客的契机是换工作,另外就是这篇文章是我在怿星解决的最后一个bug。

  问题来源是,跑在基于LwIP+FreeRTOS环境的DoIP,在反复初始化/反初始化时几次之后就会失败了。年初由于任务紧张,检查了下初始化和反初始化函数的流程,改掉了几处可能会出现问题的地方,问题依旧。但是同样的上层处理代码,在windows和linux环境下是没问题的,基本怀疑是LwIP某处不完善引起。一直拖到要离职,终于在离开的最后一天解决了,也算是给在怿星的DoIP协议栈画上一个属于自己的句号。

   LwIP 全名为 Light weight IP,意思是轻量化的 TCP/IP 协议, 是瑞典计算机科学院(SICS)的 Adam Dunkels 开发的一个小型开源的 TCP/IP 协议栈。 LwIP 的设计初衷是:用少量的资源消耗(RAM)实现一个较为完整的 TCP/IP 协议栈,其中“完整”主要指的是 TCP 协议的完整性, 实现的重点是在保持 TCP 协议主要功能的基础上减少对 RAM 的占用。此外 LwIP既可以移植到操作系统上运行,也可以在无操作系统的情况下独立运行。

2. 原因

  引起该问题的根本原因是,LwIP select函数里如果判断对应的socket没有事件产生(读/写/异常),进行简单处理后则改线程休眠,让出cpu控制权。如果在select休眠期间,进行了close socket的操作,会释放对应的socket pcb(close(socket)是成功的),然后在select休眠结束后,判断该socket资源不存在,则直接退出select函数,但是此时该socket的select_wait标志位没被清除。LwIP在分配socket时(资源都是静态分配的,类似于有一个socket数组,若分配则对应标志位为真),socket是否空闲是会对select_wait该标志位进行判断,所以即使该socket没有被使用,调用socket()函数时也会认为该socket是被占用的,所以几次之后,socket资源被假耗尽

3. 解决

  知道原因后,问题就好解决了。有以下两个解决问题的思路。

1. 更改LwIP源码,对对应的标志位进行判断和清除。该解决方案,如果能够push到LwIP主分支,则是一劳永逸的,否则如果要跟随LwIP官方更新,自己得维护一套代码,并持续merge。

2. 使用者,在使用接口时,做同步。即在select休眠期间不允许进行close socket操作,同时在close socket也不允许进入select函数。所以只要在两个函数之间加上条件判断就好。

  考虑到维护成本,最终选择方案2.

二、分析

  解决思路在上面已经给出,下面主要想从源码级对问题进行分析。原因中,涉及三个函数,

1. socket函数,即lwip_socket,函数原型如下:

int lwip_socket(int domain, int type, int protocol)

2. close函数,即lwip_close,原型如下:

int lwip_close(int s)

3. select函数, 即lwip_select(),原型如下:

intlwip_select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout)

1. 拓展

  LwIP本身提供了类似于bsd socket编程模型,同时也实现了简易版的select函数。

  关于socket编程的教程是实在太多了,在这不再重复去描述,socket编程参考链接。辅导过一些人进行socket编程,初学者包括我自己,容易忽略的一点就是,作为server时,listen-socket和accept-socket不是一回事。可以理解为listen-socket窗口,窗口只是负责监听有谁要走通道,走哪个通道,并把真正的通道--accept-socket给到上层。对于其他的,感觉跑跑示例程序,单步走一下,就基本理解了。

  在不使用select时,并没有发现socket资源释放不完全的问题。本文不展开讲解lwip select的实现,但是对于select的使用需要稍微展开下,select编程参考链接。关于select本质上是一个同步I/O函数,只不过改同步函数可以同时监控多个"IO"通道,所以也称为多路复用。熟悉了上面的socket编程后,如果需要实现多个socket同时通信的话,就应该给每个socket开一个线程,在负载不是特别高的情况下会显得效率特别低,同时线程太多,就不得不考虑资源竞争的问题,如果竞态条件太多,也容易产生问题(多线程资源竞争问题)。多路复用即是用一个线程监听多个通道(描述符),一旦某个描述符就绪(可读、可写或者异常),就通知程序进行相应的读写操作。上庙的描述,看起来select是异步的,其实不然,因为产生读写事件后,应用程序必须自己负责读写操作,读写操作本身是阻塞的,而异步I/O是不需要自己读写;同时即使没有读写事件产生,select函数本身也是阻塞的,加了超时也是阻塞的,只不过给阻塞增加了一个时间限制。

  select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。从select编程参考链接中可以看出最终每个socket都对应到每个bit上,如果对应的socket有事件产生,则会被置位。

2. 函数分析

  该节分析函数socket,close,select实现细节。LwIP版本2.1.4

2.1 socket函数

  lwip中#define socket lwip_socket.


int lwip_socket(int domain, int type, int protocol) {

    struct netconn *conn;

    int i;

    LWIP_UNUSED_ARG(domain);

    /* @todo: check this */

    /* create a netconn */

    /* 下面主要是针对不同的socket类型,分配空间,对相应的成员进行赋值,空间资源为预分配给lwip的堆空间

    */

    switch (type) {

    case SOCK_RAW:

        conn = netconn_new_with_proto_and_callback(

            DOMAIN_TO_NETCONN_TYPE(domain, NETCONN_RAW), (u8_t)protocol,

            event_callback);

        LWIP_DEBUGF(SOCKETS_DEBUG,

                    ("lwip_socket(%s, SOCK_RAW, %d) = ",

                    domain == PF_INET ? "PF_INET" : "UNKNOWN", protocol));

        break;

    case SOCK_DGRAM:

        conn = netconn_new_with_callback(

            DOMAIN_TO_NETCONN_TYPE(domain, ((protocol == IPPROTO_UDPLITE)

                                                ? NETCONN_UDPLITE

                                                : NETCONN_UDP)),

            event_callback);

        LWIP_DEBUGF(SOCKETS_DEBUG,

                    ("lwip_socket(%s, SOCK_DGRAM, %d) = ",

                    domain == PF_INET ? "PF_INET" : "UNKNOWN", protocol));

        break;

    case SOCK_STREAM:

        conn = netconn_new_with_callback(

            DOMAIN_TO_NETCONN_TYPE(domain, NETCONN_TCP), event_callback);

        LWIP_DEBUGF(SOCKETS_DEBUG,

                    ("lwip_socket(%s, SOCK_STREAM, %d) = ",

                    domain == PF_INET ? "PF_INET" : "UNKNOWN", protocol));

        break;

    default:

        LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_socket(%d, %d/UNKNOWN, %d) = -1\n",

                                    domain, type, protocol));

        set_errno(EINVAL);

        return -1;

    }

    if (!conn) {

        LWIP_DEBUGF(SOCKETS_DEBUG,

                    ("-1 / ENOBUFS (could not create netconn)\n"));

        set_errno(ENOBUFS);

        return -1;

    }

    /*

    *上面已经分配好了,对应的connection空间,最终要对应的socket上,即socket数组,见下面alloc_socket实现。

    */

    i = alloc_socket(conn, 0);

    if (i == -1) {

        netconn_delete(conn);

        set_errno(ENFILE);

        return -1;

    }

    conn->socket = i;

    LWIP_DEBUGF(SOCKETS_DEBUG, ("%d\n", i));

    set_errno(0);

    return i;

}


static int alloc_socket(struct netconn *newconn, int accepted){

  int i;

  SYS_ARCH_DECL_PROTECT(lev);

  /* allocate a new socket identifier */

  for (i = 0; i < NUM_SOCKETS; ++i) {

    /* Protect socket array */

    SYS_ARCH_PROTECT(lev);

    if (!sockets[i].conn && (sockets[i].select_waiting == 0)) {

      sockets[i].conn      = newconn;

      /* The socket is not yet known to anyone, so no need to protect

        after having marked it as used. */

      SYS_ARCH_UNPROTECT(lev);

      sockets[i].lastdata  = NULL;

      sockets[i].lastoffset = 0;

      sockets[i].rcvevent  = 0;

      /* TCP sendbuf is empty, but the socket is not yet writable until connected

      * (unless it has been created by accept()). */

      sockets[i].sendevent  = (NETCONNTYPE_GROUP(newconn->type) == NETCONN_TCP ? (accepted != 0) : 1);

      sockets[i].errevent  = 0;

      sockets[i].err        = 0;

      return i + LWIP_SOCKET_OFFSET;

    }

    SYS_ARCH_UNPROTECT(lev);

  }

  return -1;

}

  可以看到,判断socket资源是否有人在使用时,除了判断socket->conn是否为空,还会判断select_waiting是否等于0。其中select_waiting标识该socket正在被多少个线程在使用。即要释放socket资源(说释放有点不是很准确,因为在lwip中,socket资源是编译前分配的),两个重要条件是,socket->conn必须为空,并且select_waiting要为0.

2.2 close函数

  接下来看看close函数的实现,看为啥会导致资源释放不完全。


int lwip_close(int s){

  struct lwip_sock *sock;

  int is_tcp = 0;

  err_t err;

  LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_close(%d)\n", s));

  /* 本质上是,通过socket数组下标获取到socket结构体 */

  sock = get_socket(s);

  if (!sock) {

    return -1;

  }

  if (sock->conn != NULL) {

    is_tcp = NETCONNTYPE_GROUP(netconn_type(sock->conn)) == NETCONN_TCP;

  } else {

    LWIP_ASSERT("sock->lastdata == NULL", sock->lastdata == NULL);

  }

#if LWIP_IGMP

  /* drop all possibly joined IGMP memberships */

  lwip_socket_drop_registered_memberships(s);

#endif /* LWIP_IGMP */

  /* 释放从lwip内存堆里分配到空间 */

  err = netconn_delete(sock->conn);

  if (err != ERR_OK) {

    sock_set_errno(sock, err_to_errno(err));

    return -1;

  }

  /* 主要是对socket结构体成员进行反初始化,并对数据空间进行释放,看下述对该函数实现分析 */

  free_socket(sock, is_tcp);

  set_errno(0);

  return 0;

}


static void free_socket(struct lwip_sock *sock, int is_tcp){

  void *lastdata;

  lastdata        = sock->lastdata;

  sock->lastdata  = NULL;

  sock->lastoffset = 0;

  sock->err        = 0;

  /* Protect socket array */

  /* 对socket->conn进行置空 */

  SYS_ARCH_SET(sock->conn, NULL);

  /* don't use 'sock' after this line, as another task might have allocated it */

  if (lastdata != NULL) {

    if (is_tcp) {

      pbuf_free((struct pbuf *)lastdata);

    } else {

      netbuf_delete((struct netbuf *)lastdata);

    }

  }

}

  上述两个函数分析可知,close函数只能使socket->conn为空,并不能使select_waiting为0,所以其实只有close函数是不能使socket资源完全释放的。

2.3 select函数

  从select_waiting名字中能比较容易的猜到,该变量跟select函数肯定是强相关的。全局搜索select_waiting,果然只有select函数有进行写操作。下面分析select函数,该函数较长,做必要的简化。


int lwip_select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout){

  u32_t waitres = 0;

  int nready;

  fd_set lreadset, lwriteset, lexceptset;

  u32_t msectimeout;

  struct lwip_select_cb select_cb;

  int i;

  int maxfdp2;

#if LWIP_NETCONN_SEM_PER_THREAD

  int waited = 0;

#endif

  /* Go through each socket in each list to count number of sockets which

    currently match */

  /*

  *扫描所有socket对应的bit,如果有准备好,则直接将对应的bit置上,后面可以看出,该函数简单的赋值后就退出了,

  *不涉及对select_waiting的操作。

  */

  nready = lwip_selscan(maxfdp1, readset, writeset, exceptset, &lreadset, &lwriteset, &lexceptset);

  /* If we don't have any current events, then suspend if we are supposed to */

  /* 只有没有相应的socket准备好并且没有超时,才回置位select_waiting, 并挂起线程。 */

  if (!nready) {

    if (timeout && timeout->tv_sec == 0 && timeout->tv_usec == 0) {

      LWIP_DEBUGF(SOCKETS_DEBUG, ("lwip_select: no timeout, returning 0\n"));

      /* This is OK as the local fdsets are empty and nready is zero,

        or we would have returned earlier. */

      goto return_copy_fdsets;

    }

    /* 省略一堆处理,可以看到只要该socket设置了,读写异常通知,并且socket是存在的,则会将select_wainting增加1 */

    /* Increase select_waiting for each socket we are interested in */

    maxfdp2 = maxfdp1;

    for (i = LWIP_SOCKET_OFFSET; i < maxfdp1; i++) {

      if ((readset && FD_ISSET(i, readset)) ||

          (writeset && FD_ISSET(i, writeset)) ||

          (exceptset && FD_ISSET(i, exceptset))) {

        struct lwip_sock *sock;

        SYS_ARCH_PROTECT(lev);

        sock = tryget_socket(i);

        if (sock != NULL) {

          sock->select_waiting++;

          LWIP_ASSERT("sock->select_waiting > 0", sock->select_waiting > 0);

        } else {

          /* Not a valid socket */

          nready = -1;

          maxfdp2 = i;

          SYS_ARCH_UNPROTECT(lev);

          break;

        }

        SYS_ARCH_UNPROTECT(lev);

      }

    }

    if (nready >= 0) {

    /*

    *执行完上述操作,还会再扫描一次是否有socket有事件产生,删除细节。

    *因为上述,如果socket资源过多,会消耗不少资源,再扫描一次可以提高效率。

    */

      /* 休眠指定时间,让出cpu控制权 */

      waitres = sys_arch_sem_wait(SELECT_SEM_PTR(select_cb.sem), msectimeout);

    }

    /* 休眠结束, 将对应socket->select_waiting减1 */

    /* Decrease select_waiting for each socket we are interested in */

    for (i = LWIP_SOCKET_OFFSET; i < maxfdp2; i++) {

      if ((readset && FD_ISSET(i, readset)) ||

          (writeset && FD_ISSET(i, writeset)) ||

          (exceptset && FD_ISSET(i, exceptset))) {

        struct lwip_sock *sock;

        SYS_ARCH_PROTECT(lev);

        sock = tryget_socket(i);

        /* 减1,必须socket是还在的 */

        if (sock != NULL) {

          /* for now, handle select_waiting==0... */

          LWIP_ASSERT("sock->select_waiting > 0", sock->select_waiting > 0);

          if (sock->select_waiting > 0) {

            sock->select_waiting--;

          }

        } else {

          /* Not a valid socket */

          nready = -1;

        }

        SYS_ARCH_UNPROTECT(lev);

      }

    }

  }

  /* 删除不影响分析代码,感兴趣参考源码。 */

  return nready;

}

<center>这是这一张来自未来的select函数处理流程图</center>

  参考上述代码分析,特别注意socket->select_waiting加1和减1的地方,可以看到,如果socket存在且的确需要监听事件,且并不是进来事件就已经产生或者已经超时,一定会加1;然后线程会有可能会进行休眠;正常情况下,休眠结束后,socket->select_waiting减1,离开该函数,socket->select_waiting恢复原值。但是,如果在线程休眠期间,恰巧在另外一个线程进行了close操作,事件就变味了。

  如果在休眠期间进行了close(socket),则通过tyr_socket(socket)获取不到socket结构体,则socket->select_waiting不会进行减1,后面执行一系列语句后,退出该函数,socket->select_waiting没有恢复原值,且比进来时大1。针对该函数,socket->select_waiting加1的次数是>=减1的次数,所以如果只要在函数退出时没有恢复原值,则socket->select_waiting永远不可能再减为0了,此时socket资源就出现了假占用,该socket再也不能被其他人使用了。

三、解决方案

  第二章已经对产生的原因进行了分析。解决问题的思路也想一开始提到的有两种,为了不改lwip源码,使用了第二种思路。下面用伪代码给出解决方案。需要使用到两个flagclosing_socket_flag和·selecting_flag`。

thread1


int adaptor_closesocket(int socket){

    while(get_select_processing()){

        sleep(1);

    }

    set_closesocket_processing(true);

    ret = close(socket);

    set_closescoket_processing(false);

}

thread2


int select_loop(int socket){

    while(get_closesocket_processing()){

        sleep(1);

    }

    set_select_processing(true);

    select_return = select(sockMAX + 1, &read_set, NULL, &exception_set, &timeout);

    set_select_processing(false);

}

  上面的解决方案,我认为是最为简单通用的解决方案,当然针对两个flag肯定还是需要加锁的。另外还有一种思路就是使用通知类似于condition的方法。知道了错误原因,解决方法的思路就是做同步。

四、写在最后

  LwIP无疑是一个很优秀的轻量版的TCP/IP协议实现了,虽然上面的socket接口都是简化版,当时以为如果功能是支持的,在使用以为可以跟BSD的一样。因为在开发DoIP时是跨平台,上层应用代码是一样的,在windows和linux都是支持的,所以比较简单就初步定位出了问题应该是出在了LwIP协议本身,但是当时由于现象特别奇怪(略过不表),也费了一般周折才最终定位出来。一开始觉得认为这是一个bug,后面跟老虞(技术偶像)深度讨论过,觉得这也不属于LwIP本身的一个bug,感觉更像是feature实现的不够完整,但是light weight也已经足够了。同时在使用LwIP本身也学到了很多技巧,如连接符##的使用、在MCU上实现分配空间的解决方案。

image
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容