美文网首页
了解你自己的程序

了解你自己的程序

作者: 泡沫与周期_白羊Jerry | 来源:发表于2016-09-19 00:29 被阅读1026次

    前言

    因为在做nodejs程序的性能分析的时候,了解到了Perf和FlameGraph这两个神奇的工具,接着就知道了Brendan D. Gregg这个大神,跪着拜读了他的博客和他写的System Performance。从前写程序和调优只知道从设计的思路去思考,读完大神的文章,感觉真的给自己打开了一个全新的世界。

    把自己的程序看做一个黑盒,它运行的时候到底占多少内存,多少CPU?这个问题看起来不难,那么它多少时间在等待I/O,多少时间在计算,如果CPU是多核,它是否很好的平衡了负载?它访问文件系统频率多少,每次的相应时间多少?它运行中的热点是哪里,瓶颈是哪里?

    再复杂点,它在运行时内存是否足够,page cache和CPU cache命中率如何?有没有导致系统发生swap等非常耗时的操作?现在如果程序运行不稳定,如何去观察定位和调优?

    目前为止我还在每天跪着阅读大神的文章中,这篇笔记会按一定顺序记录我的很多理解。

    Perf

    Perf是一个神奇的工具,主要用于事件监测。
    每当linux内核调用某一个函数时,可以视作一个事件,Perf可以记录这些事件发生的时间和内核调用栈。

    基本用法

    perf command [options] [execute]
    

    举个例子:

    perf stat -e sched:sched_switch -a sleep 5
    

    统计5s之内,操作系统一共调用了多少个sched_switch。对linux熟悉的朋友应该知道这个表示进程切换。下面我的虚拟机里返回的结果

    Performance counter stats for 'system wide':
    
                 1,170      sched:sched_switch                                          
    
           5.001593514 seconds time elapsed
    

    也就是5秒钟之内发生了1170次进程切换。

    这里解释下这个命令,perf stat是统计事件次数,-e sched:sched_switch表示统计sched:sched_switch事件,然后本来应该只统计sleep 5这个process内部的事件的,加上-a表示统计整个系统内的事件,由于sleep 5要在5s后结束,所以这个命令的实际功能就是统计了系统5s内发生的sched:sched_switch事件个数。

    再举个栗子:

    perf record -e block:block_rq_complete --filter 'nr_sector > 200'
    

    block_rq_complete是块设备请求完成的事件,该条命令会统计所有涉及到200扇区以上的设备I/O事件。
    命令:

    perf record -e page-faults -ag
    

    则会统计所有缺页中断。

    事件列表

    调用perf list (注意root权限可以看到更多)可以查看当前支持的事件列表。

    List of pre-defined events (to be used in -e):
    
      alignment-faults                                   [Software event]
      bpf-output                                         [Software event]
      context-switches OR cs                             [Software event]
      cpu-clock                                          [Software event]
      cpu-migrations OR migrations                       [Software event]
      dummy                                              [Software event]
      emulation-faults                                   [Software event]
      major-faults                                       [Software event]
      minor-faults                                       [Software event]
      page-faults OR faults                              [Software event]
      task-clock                                         [Software event]
    
      L1-dcache-load-misses                              [Hardware cache event]
      L1-dcache-loads                                    [Hardware cache event]
      L1-dcache-stores                                   [Hardware cache event]
      L1-icache-load-misses                              [Hardware cache event]
      branch-load-misses                                 [Hardware cache event]
      branch-loads                                       [Hardware cache event]
      dTLB-load-misses                                   [Hardware cache event]
      dTLB-loads                                         [Hardware cache event]
      dTLB-store-misses                                  [Hardware cache event]
      dTLB-stores                                        [Hardware cache event]
      iTLB-load-misses                                   [Hardware cache event]
      iTLB-loads                                         [Hardware cache event]
    
      cycles-ct OR cpu/cycles-ct/                        [Kernel PMU event]
      cycles-t OR cpu/cycles-t/                          [Kernel PMU event]
      el-abort OR cpu/el-abort/                          [Kernel PMU event]
      el-capacity OR cpu/el-capacity/                    [Kernel PMU event]
    

    Perf可以监控的事件类型很多,甚至连L1 cache miss这种都可以。

    Perf 命令

    • perf stat
      stat命令用于简单统计次数

      • 统计PID进程的事件
        perf stat -p PID
      • 统计整个系统的事件
        perf stat -a sleep [seconds]
      • 统计command内的事件
        perf stat [command]
    • perf record & report/script
      stat命令只会统计事件发生次数,如果想查看更详细的信息,比如事件发生时的堆栈,就需要用到record命令了。
      perf record把统计结果放到当前目录内perf.data文件,用perf report/script命令可以解析展示统计结果。

      • 以固定频率对程序进行抽样,并且记录堆栈
        perf record -F [freq] [command] [-p PID] -g -- sleep [sec]
        //-g 表示统计stack, sec表示统计时长
        例如
       perf record -F 99 -a -g -- sleep 2
       //统计两秒内的默认事件 (cpu clock事件)
       perf script
      
       swapper     0 [000] 19822.660852:   10101010 cpu-clock: 
                   7fff810665d6 native_safe_halt ([kernel.kallsyms])
                   7fff8103ad7e default_idle ([kernel.kallsyms])
                   7fff8103b58f arch_cpu_idle ([kernel.kallsyms])
                   7fff810c5faa default_idle_call ([kernel.kallsyms])
                   7fff810c6311 cpu_startup_entry ([kernel.kallsyms])
                   7fff818239dc rest_init ([kernel.kallsyms])
                   7fff81f5d011 start_kernel ([kernel.kallsyms])
                   7fff81f5c339 x86_64_start_reservations ([kernel.kallsyms])
                   7fff81f5c485 x86_64_start_kernel ([kernel.kallsyms])
      
       swapper     0 [001] 19822.660853:   10101010 cpu-clock: 
                   7fff810665d6 native_safe_halt ([kernel.kallsyms])
                   7fff8103ad7e default_idle ([kernel.kallsyms])
                   7fff8103b58f arch_cpu_idle ([kernel.kallsyms])
                   7fff810c5faa default_idle_call ([kernel.kallsyms])
                   7fff810c6311 cpu_startup_entry ([kernel.kallsyms])
                   7fff810536e4 start_secondary ([kernel.kallsyms])
      
       swapper     0 [001] 19822.671003:   10101010 cpu-clock: 
                   7fff810665d6 native_safe_halt ([kernel.kallsyms])
                   7fff8103ad7e default_idle ([kernel.kallsyms])
                   7fff8103b58f arch_cpu_idle ([kernel.kallsyms])
                   7fff810c5faa default_idle_call ([kernel.kallsyms])
                   7fff810c6311 cpu_startup_entry ([kernel.kallsyms])
                   7fff810536e4 start_secondary ([kernel.kallsyms])
      
       swapper     0 [000] 19822.671003:   10101010 cpu-clock: 
                   7fff810665d6 native_safe_halt ([kernel.kallsyms])
                   7fff8103ad7e default_idle ([kernel.kallsyms])
                   7fff8103b58f arch_cpu_idle ([kernel.kallsyms])
                   7fff810c5faa default_idle_call ([kernel.kallsyms])
      

    由于我统计期间没有做任何事,所以每次时钟中断发生时,CPU都停留在native_safe_halt函数这里。

    nodejs对perf的支持

    由于nodejs等虚机语言通常采用了JIT技术在运行时改写函数,其堆栈符号表会在运行时变动。
    为了能让perf命令监测运行过程,nodejs提供了--perf_basic_prof参数,当加上此参数运行时,node会在/tmp目录下生成perf-PID.map文件,里面给出了地址到函数名称的映射。
    perf record通过-p PID抽样程序时,会自动去/tmp目录下找对应的map文件并加载。

    FlameGraph

    查看perf script的报告仍然不够直观,大神给出了一个工具,可以将perf统计结果转化成SVG图,并且会将相同的堆栈合并,这样可以很直观的看出来程序在那些调用上花费了大量时间。

    FlameGraph

    Markdown好像不支持SVG,只能截个图表示效果,实际的SVG是可以交互的了解具体细节。
    这样对程序的运行过程会有非常直观的了解。

    用FlameGraph调查nodejs程序内存使用

    接下来是一个用FlameGraph来检查nodejs程序的例子。

    1. 启动nodejs程序,带上 --perf-basic-prof 选项
      这里我启动了一个简单的deeplearning程序。
      node --perf-basic-prof main.js &
      进程ID为25586,于是node会在/tmp/目录下生成/tmp/perf-25586.map文件,其实文件内容就是地址和函数名的映射表。
    2. 启动perf监控程序
      这里我监控所有的缺页中断程序
      perf record -e kmem:mm_page_alloc -g -p 25586
      监控了一段时间后就Ctrl+c断开监控,此时目录下生成了perf.data文件。
    3. 输出perf记录
      调用perf script把结果存在一个临时文件中
      perf script > node.tmp
    4. 调用Flamegraph工具将其生成SVG热点图
      stackcollapse-perf.pl node.tmp | flamegraph.pl > flamegrapsh.svg
    5. 用浏览器打开svg就可以看到热点图了


      flame_graph

    可以查看具体细节:


    detail

    可以看到Rect.junc函数导致了大量的ProcessOldToNewSlot,紧接着导致了大量的缺页中断,接下来就可以调研下ProcessOldToNewSlot是什么,以及如何可以避免这种情况了。

    ftrace

    ftrace是linux提供的一个tracing工具,同perf一样可以监控很多系统的事件。
    ftrace
    Dtrace & Systemtap
    ==========
    比起Perf,Dtrace和Systemtap更为强大,它们除了可以检测事件之外,还可以在事件发生时运行指定的命令去调查更详细的信息,比如函数参数等。
    Dtrace貌似在ubuntu上的支持并不好,接下来我会花些时间去学习Systemtap。

    Linux系统提供的监测工具

    vmstat

    vmstat提供了关于系统虚拟内存的使用统计信息。
    用法是

    vmstat [options] [delay [count]]
    

    delay表示刷新频率,count表示统计次数。
    重点是options

    options

    • a
      统计active和inactive memory,正被程序引用的page为active。
    • m, slabs
      统计slab系统信息
    • s
      展示一些计数信息,
    • d
      统计硬盘信息

    vmstat 统计的信息包括

    • 进程

      • r
        状态为RUNNABLE的进程,运行中或者等待CPU。
      • b
        状态为UNINTERRUPTIBLE SLEEP的进程,一般是在等待I/O操作。
    • 内存

      • swpd
        使用的swap内存
      • free
        空闲内存数量
      • buff
        被用作buffers的内存数量
      • cache
        被用作cache的内存数量
      • inact
      • active
        inactive/active内存数量
    • Swap

      • si
        从disk swap进来的内存数量(每秒)
      • so
        换出到disk的内存数量(每秒)
    • I/O

      • bi
        每秒收到的blocks数量
      • bo
        每秒发送出去的blocks数量
    • system

      • in
        每秒收到的interrupt数量,包括时钟中断
      • cs
        每秒的context switch数量
    • cpu
      这里统计的是百分比,按CPU核数平均之后的结果

      • us
        非内核态时间占比
      • sy
        内核态时间占比
      • id
        空闲时间占比,注意这里包括了wa时间
      • wa
        等待I/O时间占比
      • st
        Time stolen from virtual machine
    • Disk Mode

      • Reads
        total: 完成的Read总数
        merged: 被merge的Read次数
        sectors: 完成的Read sector数量
        ms: 花费在read上的时间总数
      • Writes
        total: 完成的Write总数
        merged: 被合并的Write次数
        sectors: 完成的write扇区总数
        ms: 花费在write上的时间总数
      • IO
        cur: 正在进行的I/O
        s: 花费在I/O上的时间

    使用vmstat的时候,有几个很重要的概念需要理清楚:

    • active/inactive memory
      active memory指的是被某个process使用在的memory。
      inactive memory指的是被曾经运行的process使用的memory
    • buffer/cache
      有关buffer和cache的区别,我到现在还没有完全弄清楚,就目前的理解而言,buffer是供给I/O来保存传输数据块的page,而cache是用来做文件内容缓存的page。

    iostat

    iostat统计了系统运行的一些io数据。

    iostat [options] [interval [count]]
    

    重点仍然是options

    options

    • c
      展示CPU统计报告
    • d
      展示device统计报告
    • x
      展示扩展统计信息(很有用)
    • z
      忽略不活跃device

    iostat展示的信息包括

    • CPU报告
      • %user
        平均后的用户态时间占比
      • %system
        平均后的内核态时间占比
      • %iowait
        io等待时间的占比
      • %idle
        空闲时间占比
    • Device报告
      • Device:
        device name
      • tps:
        transfers per second,每秒传输请求次数
      • Blk_read/s (kB_read/s, MB_read/s):
        每秒读取Block数量或者数据量
      • Blk_wrtn/s (kB_wrtn/s, MB_wrtn/s):
        每秒写入的Block数量或者数据量
      • rrqm/s:
        每秒merged read request数量
      • wrqm/s:
        每秒merged write request数量
      • r/s:
        merge之后的每秒read request数量
      • w/s:
        merge之后的每秒write request数量
      • rsec/s (rkB/s, rMB/s):
        每秒读入数据量
      • wsec/s (wkB/s, wMB/s):
        每秒写入数据量
      • avgrq-sz:
        平均每个request的size,一般以sector为单位,每个sector是512字节
        所以一般来说:(avgrq-sz * 0.5 * (r/s + w/s) = rkB/s + wkB/s)
      • avgqu-sz:
        device的request queue的平均长度
      • await:
        平均I/O时间,从发出request到request完成
      • r_await: w_await:
        r/w 平均等待时间
      • %util:
        I/O时间占比,也就是整个系统有多少的时间是处于I/O中,当100%时,该device就接近饱和了

    mpstat

    vmstat,iostat给出的都是根据CPU核数平均之后的数据,mpstat可以统计per kernel的数据。

    vmstat [options] [delay [count]]
    

    options

    • A
      显示每个核的统计信息
    • I
      显示中断统计
    • u
      显示CPU统计数据
      一般的用法是
      mpstat -p ALL [interval]

    mpstat报告内容

    • CPU统计信息
      • CPU
        CPU核序号
      • %usr
      • %sys
      • %iowait
      • %irq
        CPU服务硬件中断时间占比
      • %soft
        CPU服务软中断占比

    uptime

    uptime主要是以15,5,1分钟为单位统计了过去这段时间CPU的负荷。

    free

    free是查看当前内存使用量的工具,它可以查看:

    • total
    • used
    • free
    • shared
    • buff/cache
    • available
      total = used + free + buff/cache
      ps

    ps命令会按进程为单位显示一些统计数据。事实上ps命令的实现就是去proc文件系统查询对应的数据。

    • %CPU
    • %MEM
    • VSZ
      virtual size
    • RSS
      resident set size

    pmap

    proc文件系统

    proc文件系统是linux提供的内核文件系统,在linux源码Documentation/filesystems/proc.txt里有详细介绍。

    proc根目录

    proc/[PID]目录

    cmdline

    cmdline给出了该process的运行命令。

    /usr/lib/at-spi2-core/at-spi2-registryd^@--use-gnome-session^@
    

    以^@(null)分割

    cwd

    指向当前工作目录的软链接

    environ

    环境变量,同cmdline一样是null分隔的字符串

    exe

    指向执行程序的软链接

    fd

    文件描述符目录,里面是全部file descriptor

    maps

    maps文件描述了程序的线性地址映射列表,看这个文件可以了解到程序当前的地址空间分布

    mem

    root

    stat

    stat文件很有意思,就是一列数字,具体的含义需要去查文档。

    statm

    statm提供了进程的内存统计数据,包括:

    • total memory
    • resident set size
    • shared pages

    status

    status提供了很多进程的监测数据,其中比较有用的有:

    • FDSize
      当前的file descriptor数量
    • VmSize
      进程线性空间大小
    • VmRss
      进程实际占用物理内存大小
    • VmPeak
      进程线性空间大小峰值
      <em>
      当进程向linux系统请求一些内存空间的时候,linux系统并不会立刻给进程分配物理页面,它只是做了一个mark,增加了一下进程的线性空间(vm_area_struct),表示这个进程又多了一块可访问地址,这些值会被统计在VmSize里,因此VmSize表示进程逻辑上的内存大小。
      当进程真正访问到请求的地址时,linux才会因为page fault去给进程真正分配物理page,这个实际分配的大小记录在VmRss里。同样,当进程内存不够用时,系统可能将其它进程的物理page断开然后swap到交换设备上,这时候,其它进程的VmRss是减小的,参见stackoverflow上的回答。
      </em>

    pagemap

    meminfo

    meminfo里有内存的很多信息

    diskstats

    diskstats文件包含了以下数据:
    1 - major number
    2 - minor mumber
    3 - device name
    4 - reads completed successfully
    5 - reads merged
    6 - sectors read
    7 - time spent reading (ms)
    8 - writes completed
    9 - writes merged
    10 - sectors written
    11 - time spent writing (ms)
    12 - I/Os currently in progress
    13 - time spent doing I/Os (ms)
    14 - weighted time spent doing I/Os (ms)
    根据这些数据可以猜想iostat没准就是通过/proc/diskstats文件来计算监测数据的,strace iostat果然证明了这个猜想

    loadavg

    看名字也能看出来大概是说啥了

    性能分析方法:

    CPU

    CPU分析太细级别的不一定有特别大的意义,统计工具有:

    • uptime
      观察CPU load average = number of [runnable, uninterruptable] processes

    • vmstat
      vmstat提供了user,system以及id的时间比率,以及r(run queue)的长度

    • mpstat
      mpstat -P ALL可以观测每一个CPU核的统计数据,如果很不均匀,可以考虑多线程来提高CPU利用率

    • pidstat
      pidstat根据CPU或者进程来统计使用情况。

    • time
      /usr/bin/time,注意不是直接的time,加上-v可以显示一个程序的统计信息。包括

      • Majo (I/O) page faults
      • Minor (reclaiming a frame) page faults
      • Swaps

      要理解上述参数可以参阅, Minor page faults表示程序访问了可以复用的page,而Major page faults表示程序访问了需要通过I/O调入的page。
      一个简单的实验就是调用两次/usr/bin/time -v firefox,第一次调用启动时间较长,400多个major page faults, 35481个minor,第二次调用就快多了,而且是0个major page faults, 34434个minor。因为linux page cache系统缓存了firefox的执行文件内容。

    • htop
      htop也是一个实时监测工具,可以用来初步判断问题所在。

    • getdelay.c
      linux源代码里Document目录下有一个getdelay.c,编译后可以通过-p PID的方式来读取某一个程序的delay信息,包括:

      • Scheduler Latency
        进程花了多少时间等待CPU调度
      • Block I/O
        进程花了多少时间等待I/O完成
      • Swapping
        进程花了多少时间等待页面调入
      • Memory reclaim
        进程花了多少时间等待cache页面分配
    • profiling
      通过perf,systemtap等profiling工具,可以查看CPU热点,分析CPU耗费在哪里。
      perf sched还可以统计scheduler latency,也就是进程切换导致的延时。(这个值我现在读的有点疑问)

    B神提到user:system CPU时间比反应了这个程序的类型,高user time说明是计算密集型。
    在B神的业务服务器上(IO密集型,大概100K syscall每秒),一般负载系数在2到8(线程数/cpu核数),user/system时间比大概是60/40。

    Memory

    Memory监测一般是跟使用语言绑定,不过也有一些从系统层级观测memory使用情况的工具。

    概念

    了解Memory工具前最好先了解linux系统里的几个概念:

    • Main Memory
      也就是物理内存
    • Virtual Memory
      进程所看到的线性地址内存
    • Resident Memory
      有实际物理内存对应的virtual memory
    • Anonymous Memory
      没有文件系统page对应的内存,一般就是进程的数据部分,包括stack和heap
    • Paging
      主存和存储设备之间的page转移
    • Anonymous Paging
      Anonymous Paging意味着把进程的stack或者heap swap出去,当再次运行的时候又会需要把它们从磁盘上读回来,这是非常hurting的现象
    • Page States
      • Unallocated
      • Allocated, unmapped
      • Allocated, mapped to main memory
      • Allocated, mapped to swap device
    • Linux Page System
      当一个page request来的时候,linux按照以下顺序分配内存page
      • Free List
        free page列表
      • Page Cache
        从文件系统用作cache的page中分配
      • Swapping
        kswapd系统线程通过swap一些page出去来腾出空间
      • OOM Killer
        杀死一些进程来空出空间
      • Page回收
        Linux将page分为以下几类:
        1. 不可回收页:
          空闲页,保留页(PG_reserved),内核分配页,进程内核态堆栈页,临时锁定页(PG_locked)
        2. 可交换页
          用户态anonymous页(堆栈,堆),回收时将内容保存到交换区
        3. 可同步页
          用户态地址空间(映射文件),有对应磁盘文件页,回收时需要做同步操作

    检测工具及方法

    • vmstat
      • swpd
        总共swap出去的memory
      • free
        当前free memory
      • si, so
        swapped in swapped out memory,这两个值反应了系统memory压力
    • slabtop
      slabtop可以了解linux kernel slab系统的内存使用情况
    • Systemtap, perf
      这些tracer可以监控系统事件了解内存使用情况

    File System

    同样,首先需要大致了解一些File system的概念

    • File system

    • Page Cache
      Linux使用统一的Page Cache系统来做磁盘和块设备的Cache。Page Cache中的page内包含的内容可能有:

      1. 普通文件的内容,page cache通过文件inode中的address_space结构对应起来
      2. 目录内容,linux像处理普通文件一样处理目录文件
      3. 从块设备直接读出的内容(绕过文件系统)
      4. 用户process被swap out的内容,虽然被swap out,但是有些内容会被先cache起来
      5. 特殊的文件系统文件,比如shm文件系统
    • Linux系统是如何读取一个文件的(非Direct I/O, Memory Mapping, Asynchronous)

      1. read调用,最后到generic_file_read(filep, buf, count, ppos),参数分别表示文件句柄,存放内容的数组,读取内容数量和起始偏移量。
      2. generic_file_read初始化一个iovec,存放buf和count和一个kiocb,用来控制I/O过程,然后call __generic_file_aio_read:
      3. 检查缓冲区有效
      4. 建立一个read_descriptor_t,表示读取操作状态
      5. do_generic_file_read(filp, ppos, read_descriptor_t, &file_read_actor)
      6. do_generic_file_read会执行实际的拷贝,过程如下:
      7. 取得filep->f_mapping,address_space对象
      8. 将文件看过页数组,算出起止序号
      9. 循环读入页,(read_page方法):
      * 处理预读页
      * 在页缓存中寻找页,找不到则申请空白页
      * 在页缓存中找到页的话,读取结束
      * 调用address_space->readpage方法读取数据到页缓存
      * 调用file_read_actor把数据拷贝到用户缓存
      
      1. 修改文件inode的update_atime,mark this inode dirty
        <em>
        read_page方法:
        普通文件read_page: 首先计算文件在磁盘上的块号,如果是连续的,发出一个block I/O请求,否则用一次一块的方法读取。
        块设备文件read_page: 将page看做块缓冲区,逐块读取。
        </em>
    • Linux系统如何写入一个文件

      1. generic_file_write(filep, buf, count, ppos)
      2. 获取文件inode->i_sem信号量用以控制同步写入
      3. 创建kiocb, iovec,调用__generic_file_aio_write_block:
      • 首先在page cache中搜索对应页
      • 如果没有对应页,新建一个页框,调用address_space->prepare_write
      • 拷贝写入内容到页中,调用address_space->commit_write,标记页dirty
      1. 释放inode->i_sem
      2. 如果需要sync,调用address_space的writepages方法
      3. 到这一步,write操作已经返回了。标记为dirty的page最终写到磁盘上,则是延迟执行的,调用address_space->writepages方法
    • 内存映射mmap
      内存映射简单的说,就是直接把文件内容读入page cache,并通过修改进程的mm_struct中的vm_area_struct来让一部分线性空间指向page cache中的page的物理地址。这样进程如果只需要读取,就相对read调用少了一次往user buffer里拷贝的过程,但如果进程要把数据复制出来的话,其实跟直接调用read区别不大。
      同样,文件内容读取也是延后的,直到进程访问地址产生page fault时,操作系统才会去读入文件内容到对应page frame。
      所以修改内存数据会直接修改page cache中的page内容,导致page为dirty,操作系统之后会将脏page写入磁盘,更新本地文件。

    • Direct I/O
      Linux还提供一个Direct I/O,数据直接从设备传递到用户空间,绕过文件缓存系统,好处是少了一次数据从系统内核到用户空间的拷贝。坏处是用户空间的页将会被锁定不能换出,这是与linux系统内存使用理念相悖的。

    检测工具

    Network

    • netstat -s
    • netstat -i
    • tcpdump
    • stap/perf

    相关文章

      网友评论

          本文标题:了解你自己的程序

          本文链接:https://www.haomeiwen.com/subject/xxkgettx.html